@phnx-labs/agents-cli 1.20.87 → 1.20.88

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +3 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +0 -19
  5. package/dist/commands/doctor.js +219 -305
  6. package/dist/commands/exec.js +7 -19
  7. package/dist/commands/inspect.js +3 -5
  8. package/dist/commands/routines.js +2 -2
  9. package/dist/commands/sessions.js +1 -0
  10. package/dist/commands/ssh.js +3 -3
  11. package/dist/commands/usage.d.ts +3 -2
  12. package/dist/commands/usage.js +2 -9
  13. package/dist/lib/agents.d.ts +31 -1
  14. package/dist/lib/agents.js +55 -0
  15. package/dist/lib/command-skills.d.ts +10 -0
  16. package/dist/lib/command-skills.js +14 -0
  17. package/dist/lib/commands.js +19 -1
  18. package/dist/lib/daemon.js +17 -2
  19. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  20. package/dist/lib/devices/doctor-findings.js +893 -0
  21. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  22. package/dist/lib/devices/fleet-divergence.js +34 -10
  23. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  24. package/dist/lib/devices/fleet-inventory.js +56 -8
  25. package/dist/lib/exec.d.ts +14 -3
  26. package/dist/lib/exec.js +41 -8
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/project-resources.js +34 -20
  29. package/dist/lib/runner.d.ts +14 -1
  30. package/dist/lib/runner.js +37 -8
  31. package/dist/lib/sandbox.d.ts +2 -0
  32. package/dist/lib/sandbox.js +38 -0
  33. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  34. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  35. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  36. package/dist/lib/secrets/rc-hygiene.js +0 -24
  37. package/dist/lib/session/active.d.ts +6 -6
  38. package/dist/lib/session/active.js +6 -6
  39. package/dist/lib/session/discover.d.ts +5 -0
  40. package/dist/lib/session/discover.js +137 -1
  41. package/dist/lib/session/parse.d.ts +2 -0
  42. package/dist/lib/session/parse.js +76 -37
  43. package/dist/lib/session/sync/agents.js +0 -0
  44. package/dist/lib/session/types.d.ts +1 -1
  45. package/dist/lib/session/types.js +1 -1
  46. package/dist/lib/staleness/detectors/commands.js +14 -5
  47. package/dist/lib/staleness/types.d.ts +2 -0
  48. package/dist/lib/staleness/writers/commands.js +13 -7
  49. package/dist/lib/usage.d.ts +72 -1
  50. package/dist/lib/usage.js +21 -27
  51. package/dist/lib/versions.js +30 -13
  52. package/package.json +1 -1
@@ -100,11 +100,49 @@ export function prepareJobHome(config) {
100
100
  else if (config.agent === 'gemini') {
101
101
  generateGeminiConfig(overlayHome, config);
102
102
  }
103
+ else if (config.agent === 'cursor') {
104
+ generateCursorConfig(overlayHome);
105
+ }
103
106
  if (config.allow?.dirs) {
104
107
  symlinkAllowedDirs(overlayHome, config.allow.dirs);
105
108
  }
106
109
  return overlayHome;
107
110
  }
111
+ /** Link this host's Cursor login and CLI config into the disposable overlay. */
112
+ export function generateCursorConfig(overlayHome) {
113
+ const realConfigHome = process.env.XDG_CONFIG_HOME || path.join(resolveRealHome(), '.config');
114
+ const realAuth = path.join(realConfigHome, 'cursor', 'auth.json');
115
+ if (!fs.existsSync(realAuth))
116
+ return;
117
+ const overlayCursorDir = path.join(overlayHome, '.config', 'cursor');
118
+ fs.mkdirSync(overlayCursorDir, { recursive: true });
119
+ const overlayAuth = path.join(overlayCursorDir, 'auth.json');
120
+ if (process.platform === 'win32') {
121
+ // File symlinks need Developer Mode on Windows. A same-volume hard link
122
+ // shares the credential inode without copying its contents to another host.
123
+ try {
124
+ fs.linkSync(realAuth, overlayAuth);
125
+ }
126
+ catch { /* cross-volume or link creation refused: Cursor will fail auth loudly */ }
127
+ }
128
+ else {
129
+ fs.symlinkSync(realAuth, overlayAuth);
130
+ }
131
+ // Setting XDG_CONFIG_HOME makes Cursor look for cli-config.json beside auth.json.
132
+ // This file carries account identity (authId, displayName, email, userId) as
133
+ // well as preferences, so it must remain linked rather than copied.
134
+ const realCliConfig = path.join(resolveRealHome(), '.cursor', 'cli-config.json');
135
+ if (fs.existsSync(realCliConfig)) {
136
+ const overlayCliConfig = path.join(overlayCursorDir, 'cli-config.json');
137
+ try {
138
+ if (process.platform === 'win32')
139
+ fs.linkSync(realCliConfig, overlayCliConfig);
140
+ else
141
+ fs.symlinkSync(realCliConfig, overlayCliConfig);
142
+ }
143
+ catch { /* cross-volume or link creation refused: Cursor will fail auth loudly */ }
144
+ }
145
+ }
108
146
  /** Remove a job's overlay HOME directory entirely. */
109
147
  export function cleanJobHome(name) {
110
148
  const overlayHome = getJobHomePath(name);
@@ -47,9 +47,3 @@ export declare function scanRcExports(basename: string, content: string): RcSecr
47
47
  * unreadable files are skipped silently — an advisory, not a hard requirement.
48
48
  */
49
49
  export declare function scanUserRcFiles(homeDir?: string): RcSecretFinding[];
50
- /**
51
- * Build the advisory lines for `agents doctor` from a set of findings. Returns
52
- * `[]` when there is nothing to report. The first line is the headline; the
53
- * rest are indented detail. Names only — never values.
54
- */
55
- export declare function rcSecretWarningLines(findings: RcSecretFinding[]): string[];
@@ -128,27 +128,3 @@ export function scanUserRcFiles(homeDir = os.homedir()) {
128
128
  }
129
129
  return out;
130
130
  }
131
- /**
132
- * Build the advisory lines for `agents doctor` from a set of findings. Returns
133
- * `[]` when there is nothing to report. The first line is the headline; the
134
- * rest are indented detail. Names only — never values.
135
- */
136
- export function rcSecretWarningLines(findings) {
137
- if (findings.length === 0)
138
- return [];
139
- const lines = [];
140
- const master = findings.filter((f) => f.isMasterPassphrase);
141
- const others = findings.filter((f) => !f.isMasterPassphrase);
142
- const total = findings.length;
143
- lines.push(`${total} credential-shaped export${total === 1 ? '' : 's'} found in shell rc files — ` +
144
- `readable from /proc/<pid>/environ by any same-user process.`);
145
- for (const f of master) {
146
- lines.push(`${f.file}:${f.line} ${f.name} — the file-store master key. Move it off-env to ` +
147
- `~/.agents/.secrets-key/passphrase (chmod 600) and delete the export.`);
148
- }
149
- for (const f of others) {
150
- lines.push(`${f.file}:${f.line} ${f.name} — move to \`agents secrets\` and inject via \`agents secrets exec\`.`);
151
- }
152
- lines.push('Rule: no credentials in env vars or shell config. Use the keychain-backed store.');
153
- return lines;
154
- }
@@ -298,7 +298,7 @@ export declare function sessionFileTimes(sessionFile: string | undefined): {
298
298
  export declare function lifecycleStatus(pidAlive: boolean, mtimeMs: number | undefined, nowMs?: number): ActiveStatus | undefined;
299
299
  /**
300
300
  * The ONE place a fallback status is decided when no rich transcript state is
301
- * available — an opaque kind we cannot parse (cursor, openclaw), or a transcript
301
+ * available — an opaque kind we cannot parse (openclaw), or a transcript
302
302
  * whose parse/tail was empty or unreadable. Honest by construction: computed from
303
303
  * PID + mtime, never a fabricated `idle`.
304
304
  *
@@ -316,13 +316,13 @@ export declare function resolveFallbackStatus(sessionFile: string | undefined, p
316
316
  * Locate the live transcript for an agent process. Claude files are keyed by cwd
317
317
  * (+ optional session uuid), so they resolve straight off disk. Every OTHER
318
318
  * tracked harness — Codex (date-partitioned), plus grok / droid / rush / gemini /
319
- * kimi / hermes / opencode / antigravity (per-session dirs, SQLite, single-JSON)
319
+ * kimi / hermes / opencode / antigravity / cursor (per-session dirs, SQLite, single-JSON)
320
320
  * — is resolved through the session index by cwd: the newest indexed transcript
321
321
  * for that cwd, bounded by ACTIVE_SESSION_STALE_MS so a live pid never borrows a
322
322
  * weeks-old transcript. This is what lets a live NON-claude/codex agent get a real
323
323
  * status instead of falling through to `unknown` (the file feeds
324
- * {@link computeLiveSignals}). An opaque kind we don't track (cursor) still yields
325
- * undefined here and degrades honestly to a live `running`.
324
+ * {@link computeLiveSignals}). An opaque kind we don't track still yields undefined
325
+ * here and degrades honestly to a live `running`.
326
326
  */
327
327
  export declare function findSessionFileForKind(kind: string, cwd?: string, sessionId?: string): string | undefined;
328
328
  /** Live per-session signals derived from one transcript-tail read. */
@@ -339,10 +339,10 @@ interface LiveSignals {
339
339
  * Claude/Codex take the fast bounded byte-tail ({@link readSessionTailWithRaw}) —
340
340
  * the hot path, and the only two that also yield throughput (their raw lines
341
341
  * carry usage the event model drops). EVERY OTHER tracked harness (grok, droid,
342
- * rush, gemini, kimi, hermes, opencode, antigravity) is parsed with its own
342
+ * rush, gemini, kimi, hermes, opencode, antigravity, cursor) is parsed with its own
343
343
  * parser and run through the SAME {@link inferSessionState}, so a live
344
344
  * non-claude/codex agent gets a real working/waiting/idle instead of the blanket
345
- * `unknown` it used to fall through to. An opaque/untracked kind (cursor) or an
345
+ * `unknown` it used to fall through to. An opaque/untracked kind or an
346
346
  * unreadable/empty transcript yields an empty signal set, and the caller's
347
347
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
348
348
  */
@@ -358,7 +358,7 @@ export function lifecycleStatus(pidAlive, mtimeMs, nowMs = Date.now()) {
358
358
  }
359
359
  /**
360
360
  * The ONE place a fallback status is decided when no rich transcript state is
361
- * available — an opaque kind we cannot parse (cursor, openclaw), or a transcript
361
+ * available — an opaque kind we cannot parse (openclaw), or a transcript
362
362
  * whose parse/tail was empty or unreadable. Honest by construction: computed from
363
363
  * PID + mtime, never a fabricated `idle`.
364
364
  *
@@ -379,13 +379,13 @@ export function resolveFallbackStatus(sessionFile, pidAlive, nowMs = Date.now())
379
379
  * Locate the live transcript for an agent process. Claude files are keyed by cwd
380
380
  * (+ optional session uuid), so they resolve straight off disk. Every OTHER
381
381
  * tracked harness — Codex (date-partitioned), plus grok / droid / rush / gemini /
382
- * kimi / hermes / opencode / antigravity (per-session dirs, SQLite, single-JSON)
382
+ * kimi / hermes / opencode / antigravity / cursor (per-session dirs, SQLite, single-JSON)
383
383
  * — is resolved through the session index by cwd: the newest indexed transcript
384
384
  * for that cwd, bounded by ACTIVE_SESSION_STALE_MS so a live pid never borrows a
385
385
  * weeks-old transcript. This is what lets a live NON-claude/codex agent get a real
386
386
  * status instead of falling through to `unknown` (the file feeds
387
- * {@link computeLiveSignals}). An opaque kind we don't track (cursor) still yields
388
- * undefined here and degrades honestly to a live `running`.
387
+ * {@link computeLiveSignals}). An opaque kind we don't track still yields undefined
388
+ * here and degrades honestly to a live `running`.
389
389
  */
390
390
  export function findSessionFileForKind(kind, cwd, sessionId) {
391
391
  if (!cwd)
@@ -432,10 +432,10 @@ function parseTailEventsForKind(agent, sessionFile) {
432
432
  * Claude/Codex take the fast bounded byte-tail ({@link readSessionTailWithRaw}) —
433
433
  * the hot path, and the only two that also yield throughput (their raw lines
434
434
  * carry usage the event model drops). EVERY OTHER tracked harness (grok, droid,
435
- * rush, gemini, kimi, hermes, opencode, antigravity) is parsed with its own
435
+ * rush, gemini, kimi, hermes, opencode, antigravity, cursor) is parsed with its own
436
436
  * parser and run through the SAME {@link inferSessionState}, so a live
437
437
  * non-claude/codex agent gets a real working/waiting/idle instead of the blanket
438
- * `unknown` it used to fall through to. An opaque/untracked kind (cursor) or an
438
+ * `unknown` it used to fall through to. An opaque/untracked kind or an
439
439
  * unreadable/empty transcript yields an empty signal set, and the caller's
440
440
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
441
441
  */
@@ -622,6 +622,11 @@ export declare function __codexScanBranchCountsForTest(): {
622
622
  };
623
623
  /** Test seam: reset the Codex parse-branch counters to observe a scan from a clean slate. */
624
624
  export declare function __resetCodexScanBranchCountsForTest(): void;
625
+ /** Parse one Cursor transcript and enrich it with the matching chat meta.json. */
626
+ export declare function readCursorMeta(filePath: string, currentVersion?: string): {
627
+ meta: SessionMeta;
628
+ content: string;
629
+ } | null;
625
630
  /** Parse a single Kimi session state.json file to extract session metadata. */
626
631
  export declare function readKimiMeta(filePath: string, priorRow?: {
627
632
  parserState: string | null;
@@ -23,7 +23,7 @@ import { getConfigSymlinkVersion } from '../shims.js';
23
23
  import { SESSION_AGENTS } from './types.js';
24
24
  import { deriveShortId } from './short-id.js';
25
25
  import { extractSessionTopic } from './prompt.js';
26
- import { parseAntigravity } from './parse.js';
26
+ import { parseAntigravity, parseCursor } from './parse.js';
27
27
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
28
28
  import { costOfUsage } from '../pricing/index.js';
29
29
  import { machineForSessionFile } from './origin-machine.js';
@@ -221,6 +221,7 @@ function dispatchAgentScan(agent, onProgress) {
221
221
  case 'kimi': return scanKimiIncremental(onProgress);
222
222
  case 'droid': return scanDroidIncremental(onProgress);
223
223
  case 'grok': return scanGrokIncremental(onProgress);
224
+ case 'cursor': return scanCursorIncremental(onProgress);
224
225
  default: return Promise.resolve();
225
226
  }
226
227
  }
@@ -660,6 +661,7 @@ const SESSION_ROOT_SPECS = [
660
661
  { agent: 'droid', subdir: 'sessions' },
661
662
  { agent: 'kimi', subdir: 'sessions' },
662
663
  { agent: 'grok', subdir: 'sessions' },
664
+ { agent: 'cursor', subdir: 'projects' },
663
665
  ];
664
666
  function sessionRootSubdir(agent) {
665
667
  return SESSION_ROOT_SPECS.find((spec) => spec.agent === agent)?.subdir ?? null;
@@ -752,6 +754,13 @@ async function readRoutineArchiveMeta(agent, filePath) {
752
754
  const result = await readCodexMeta(filePath);
753
755
  return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
754
756
  }
757
+ if (agent === 'cursor') {
758
+ // PR #1723 archives Cursor routine transcripts; preserve version resolution
759
+ // when this reader consumes those archives.
760
+ const currentVersion = await getCurrentAgentVersion('cursor');
761
+ const result = readCursorMeta(filePath, currentVersion);
762
+ return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
763
+ }
755
764
  return null;
756
765
  }
757
766
  async function scanRoutineArchivesIncremental(agent, onProgress) {
@@ -3601,6 +3610,133 @@ function sumKnownNumbers(values) {
3601
3610
  // Time range parsing
3602
3611
  // ---------------------------------------------------------------------------
3603
3612
  // ---------------------------------------------------------------------------
3613
+ // Cursor
3614
+ // ---------------------------------------------------------------------------
3615
+ // Cursor writes the conversation to
3616
+ // projects/<encoded-cwd>/agent-transcripts/<uuid>/<uuid>.jsonl and metadata to
3617
+ // chats/<workspace-hash>/<uuid>/meta.json. Discovery deliberately starts from
3618
+ // transcripts, then joins metadata by UUID: chat directories with no transcript
3619
+ // are empty or abandoned sessions and must not become broken zero-event rows.
3620
+ // Routine archives may contain only the transcript; those remain browsable with
3621
+ // file timestamps and without guessed cwd/title metadata.
3622
+ /** Incrementally re-scan changed Cursor transcript files and upsert into the DB. */
3623
+ async function scanCursorIncremental(onProgress) {
3624
+ const currentVersion = await getCurrentAgentVersion('cursor');
3625
+ const prestat = [];
3626
+ for (const projectsDir of getAgentSessionDirs('cursor', 'projects')) {
3627
+ collectCursorTranscripts(projectsDir, prestat);
3628
+ }
3629
+ const changed = filterChangedEntries(prestat);
3630
+ if (changed.length === 0)
3631
+ return;
3632
+ onProgress?.({ agent: 'cursor', parsed: 0, total: changed.length });
3633
+ const entries = [];
3634
+ const touched = [];
3635
+ const seen = new Set();
3636
+ let parsed = 0;
3637
+ for (const { filePath, scan } of changed) {
3638
+ try {
3639
+ const result = readCursorMeta(filePath, currentVersion);
3640
+ if (result && !seen.has(result.meta.id)) {
3641
+ seen.add(result.meta.id);
3642
+ entries.push({ meta: result.meta, content: result.content, scan });
3643
+ }
3644
+ else {
3645
+ touched.push({ filePath, scan });
3646
+ }
3647
+ }
3648
+ catch {
3649
+ touched.push({ filePath, scan });
3650
+ }
3651
+ parsed++;
3652
+ onProgress?.({ agent: 'cursor', parsed, total: changed.length });
3653
+ }
3654
+ upsertSessionsBatch(entries);
3655
+ recordScans(touched);
3656
+ }
3657
+ function collectCursorTranscripts(projectsDir, out) {
3658
+ let projectNames;
3659
+ try {
3660
+ projectNames = fs.readdirSync(projectsDir);
3661
+ }
3662
+ catch {
3663
+ return;
3664
+ }
3665
+ for (const projectName of projectNames) {
3666
+ const transcriptsDir = path.join(projectsDir, projectName, 'agent-transcripts');
3667
+ for (const f of walkForFilesWithStat(transcriptsDir, '.jsonl', 100_000)) {
3668
+ const sessionId = path.basename(path.dirname(f.path));
3669
+ if (path.basename(f.path) !== `${sessionId}.jsonl`)
3670
+ continue;
3671
+ out.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
3672
+ }
3673
+ }
3674
+ }
3675
+ function readCursorChatMeta(filePath, sessionId) {
3676
+ const projectDir = path.dirname(path.dirname(path.dirname(filePath)));
3677
+ const projectsDir = path.dirname(projectDir);
3678
+ const chatsDir = path.join(path.dirname(projectsDir), 'chats');
3679
+ let workspaceHashes;
3680
+ try {
3681
+ workspaceHashes = fs.readdirSync(chatsDir);
3682
+ }
3683
+ catch {
3684
+ return undefined;
3685
+ }
3686
+ for (const workspaceHash of workspaceHashes) {
3687
+ const metaPath = path.join(chatsDir, workspaceHash, sessionId, 'meta.json');
3688
+ try {
3689
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
3690
+ }
3691
+ catch {
3692
+ // This workspace hash does not own the session, or its metadata is unreadable.
3693
+ }
3694
+ }
3695
+ return undefined;
3696
+ }
3697
+ /** Parse one Cursor transcript and enrich it with the matching chat meta.json. */
3698
+ export function readCursorMeta(filePath, currentVersion) {
3699
+ const sessionId = path.basename(filePath).replace(/\.jsonl$/, '');
3700
+ if (!sessionId || path.basename(path.dirname(filePath)) !== sessionId)
3701
+ return null;
3702
+ const events = parseCursor(filePath);
3703
+ if (events.length === 0)
3704
+ return null;
3705
+ const chatMeta = readCursorChatMeta(filePath, sessionId);
3706
+ const stat = safeStatSync(filePath);
3707
+ const createdAtMs = typeof chatMeta?.createdAtMs === 'number' ? chatMeta.createdAtMs : undefined;
3708
+ const updatedAtMs = typeof chatMeta?.updatedAtMs === 'number' ? chatMeta.updatedAtMs : undefined;
3709
+ const timestamp = createdAtMs !== undefined
3710
+ ? new Date(createdAtMs).toISOString()
3711
+ : stat ? stat.mtime.toISOString() : new Date().toISOString();
3712
+ const lastActivity = updatedAtMs !== undefined
3713
+ ? new Date(updatedAtMs).toISOString()
3714
+ : stat ? stat.mtime.toISOString() : timestamp;
3715
+ const cwd = normalizeCwd(typeof chatMeta?.cwd === 'string' ? chatMeta.cwd : '');
3716
+ const userTexts = events
3717
+ .filter((event) => event.type === 'message' && event.role === 'user' && event.content)
3718
+ .map((event) => event.content);
3719
+ const firstUserText = userTexts[0];
3720
+ const title = typeof chatMeta?.title === 'string' && chatMeta.title.trim()
3721
+ ? chatMeta.title.trim()
3722
+ : undefined;
3723
+ const meta = {
3724
+ id: sessionId,
3725
+ shortId: deriveShortId(sessionId),
3726
+ agent: 'cursor',
3727
+ timestamp,
3728
+ lastActivity,
3729
+ project: cwd ? path.basename(cwd) : undefined,
3730
+ cwd,
3731
+ filePath,
3732
+ version: resolveSessionVersion('cursor', filePath, undefined, currentVersion),
3733
+ topic: firstUserText ? extractSessionTopic(firstUserText) : undefined,
3734
+ label: title,
3735
+ messageCount: events.filter((event) => event.type === 'message').length,
3736
+ };
3737
+ return { meta, content: userTexts.join('\n') };
3738
+ }
3739
+ // ---------------------------------------------------------------------------
3604
3740
  // Kimi
3605
3741
  // ---------------------------------------------------------------------------
3606
3742
  // Kimi stores sessions under ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/.
@@ -109,6 +109,8 @@ export declare function parseRush(filePath: string): SessionEvent[];
109
109
  export declare function parseHermes(filePath: string): SessionEvent[];
110
110
  /** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
111
111
  export declare function parseKimi(filePath: string): SessionEvent[];
112
+ /** Parse Cursor's Anthropic-shaped message JSONL transcript. */
113
+ export declare function parseCursor(filePath: string): SessionEvent[];
112
114
  /**
113
115
  * Parse a Droid (Factory) JSONL session file into normalized events. Droid
114
116
  * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
@@ -154,6 +154,9 @@ export function parseSession(filePath, agent) {
154
154
  case 'droid':
155
155
  events = parseDroid(filePath);
156
156
  break;
157
+ case 'cursor':
158
+ events = parseCursor(filePath);
159
+ break;
157
160
  }
158
161
  // Chokepoint: every string field that originated in an untrusted session
159
162
  // file gets stripped of terminal escapes here, so renderers downstream can
@@ -194,6 +197,8 @@ export function detectAgent(filePath) {
194
197
  return 'kimi';
195
198
  if (filePath.includes('/.factory/') || filePath.includes('\\.factory\\'))
196
199
  return 'droid';
200
+ if (filePath.includes('/.cursor/') || filePath.includes('\\.cursor\\'))
201
+ return 'cursor';
197
202
  // Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
198
203
  const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
199
204
  if (cloudMatch)
@@ -1617,20 +1622,45 @@ export function parseKimi(filePath) {
1617
1622
  return events;
1618
1623
  }
1619
1624
  // ---------------------------------------------------------------------------
1620
- // Droid (Factory) parser
1625
+ // Cursor and Droid (Factory) parsers
1621
1626
  // ---------------------------------------------------------------------------
1627
+ /** Parse Cursor's Anthropic-shaped message JSONL transcript. */
1628
+ export function parseCursor(filePath) {
1629
+ return parseAnthropicMessageJsonl(filePath, 'cursor');
1630
+ }
1622
1631
  /**
1623
- * Parse a Droid (Factory) JSONL session file into normalized events. Droid
1624
- * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
1625
- * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
1626
- * tool_result), so block handling mirrors the Claude parser.
1632
+ * Cursor stamps the first user turn as
1633
+ * `<timestamp>Sunday, Aug 2, 2026, 3:51 AM (UTC-7)</timestamp>` followed by the
1634
+ * real question in `<user_query>`. `Date.parse` silently DISCARDS the `(UTC-7)`
1635
+ * parenthetical and reads the rest as local time, so the offset has to be applied
1636
+ * by hand — otherwise the recovered instant is wrong on every machine whose zone
1637
+ * differs from the one that wrote the transcript.
1627
1638
  */
1628
- export function parseDroid(filePath) {
1639
+ function parseCursorUserText(text) {
1640
+ const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
1641
+ const stamp = text.match(/<timestamp>\s*([\s\S]*?)\s*<\/timestamp>/)?.[1]?.trim();
1642
+ return { text: (query?.[1] ?? text).trim(), timestamp: parseCursorTimestamp(stamp) };
1643
+ }
1644
+ function parseCursorTimestamp(stamp) {
1645
+ if (!stamp)
1646
+ return undefined;
1647
+ const offset = stamp.match(/\(UTC([+-])(\d{1,2})(?::(\d{2}))?\)/);
1648
+ // Without a declared offset there is no instant to recover -- guessing the
1649
+ // local zone would be worse than falling back to the file mtime.
1650
+ if (!offset)
1651
+ return undefined;
1652
+ const wall = Date.parse(`${stamp.replace(/\s*\(UTC[^)]*\)\s*/, " ").trim()} UTC`);
1653
+ if (Number.isNaN(wall))
1654
+ return undefined;
1655
+ const offsetMs = (Number(offset[2]) * 60 + Number(offset[3] ?? 0)) * 60_000;
1656
+ return new Date(offset[1] === "-" ? wall + offsetMs : wall - offsetMs).toISOString();
1657
+ }
1658
+ function parseAnthropicMessageJsonl(filePath, agent) {
1629
1659
  const content = safeReadSessionFile(filePath);
1630
1660
  const lines = content.split('\n').filter(l => l.trim());
1631
1661
  const events = [];
1632
- // Map tool_use id -> {tool, args} for correlating with tool_result.
1633
1662
  const toolUseMap = new Map();
1663
+ const fallbackTimestamp = fs.statSync(filePath).mtime.toISOString();
1634
1664
  for (const line of lines) {
1635
1665
  let raw;
1636
1666
  try {
@@ -1639,33 +1669,42 @@ export function parseDroid(filePath) {
1639
1669
  catch {
1640
1670
  continue;
1641
1671
  }
1642
- if (raw.type !== 'message')
1672
+ if (raw.type === 'turn_ended' || (agent === 'droid' && raw.type !== 'message'))
1643
1673
  continue;
1644
- const message = raw.message || {};
1645
- const role = message.role === 'user' ? 'user' : 'assistant';
1646
- const timestamp = raw.timestamp || new Date().toISOString();
1674
+ const message = raw.message;
1675
+ const wireRole = agent === 'cursor' ? raw.role : message?.role;
1676
+ if (!message || (agent === 'cursor' && !['user', 'assistant'].includes(wireRole)))
1677
+ continue;
1678
+ const role = wireRole === 'user' ? 'user' : 'assistant';
1679
+ let timestamp = raw.timestamp || fallbackTimestamp;
1647
1680
  const blocks = message.content;
1648
- // Plain-string content (rare) renders as a single message.
1649
1681
  if (typeof blocks === 'string') {
1650
- const text = blocks.trim();
1682
+ const parsed = agent === 'cursor' && role === 'user'
1683
+ ? parseCursorUserText(blocks)
1684
+ : { text: blocks.trim(), timestamp: undefined };
1685
+ const text = parsed.text;
1686
+ timestamp = parsed.timestamp || timestamp;
1651
1687
  if (text)
1652
- events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1688
+ events.push({ type: 'message', agent, timestamp, role, content: text });
1653
1689
  continue;
1654
1690
  }
1655
1691
  if (!Array.isArray(blocks))
1656
1692
  continue;
1657
1693
  for (const block of blocks) {
1658
1694
  if (block.type === 'text') {
1659
- const text = (block.text || '').trim();
1660
- // Skip injected context blocks (date, skills list) on the first user turn.
1695
+ const parsed = agent === 'cursor' && role === 'user'
1696
+ ? parseCursorUserText(block.text || '')
1697
+ : { text: (block.text || '').trim(), timestamp: undefined };
1698
+ const text = parsed.text;
1699
+ timestamp = parsed.timestamp || timestamp;
1661
1700
  if (text && !(role === 'user' && text.startsWith('<system-reminder>'))) {
1662
- events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1701
+ events.push({ type: 'message', agent, timestamp, role, content: text });
1663
1702
  }
1664
1703
  }
1665
1704
  else if (block.type === 'thinking') {
1666
1705
  const thinkingText = (block.thinking || '').trim();
1667
1706
  if (thinkingText)
1668
- events.push({ type: 'thinking', agent: 'droid', timestamp, content: thinkingText });
1707
+ events.push({ type: 'thinking', agent, timestamp, content: thinkingText });
1669
1708
  }
1670
1709
  else if (block.type === 'tool_use') {
1671
1710
  const toolName = block.name || 'unknown';
@@ -1674,38 +1713,29 @@ export function parseDroid(filePath) {
1674
1713
  toolUseMap.set(block.id, { tool: toolName, args: toolInput });
1675
1714
  events.push({
1676
1715
  type: 'tool_use',
1677
- agent: 'droid',
1716
+ agent,
1678
1717
  timestamp,
1679
1718
  tool: toolName,
1680
1719
  args: toolInput,
1681
1720
  path: toolInput.file_path || toolInput.path || undefined,
1682
- command: (toolName === 'Bash' || toolName === 'Execute') ? toolInput.command : undefined,
1721
+ command: (toolName === 'Bash' || toolName === 'Execute' || toolName === 'Shell') ? toolInput.command : undefined,
1683
1722
  });
1684
1723
  }
1685
1724
  else if (block.type === 'tool_result') {
1686
1725
  const toolId = block.tool_use_id;
1687
1726
  const toolInfo = toolId ? toolUseMap.get(toolId) : undefined;
1688
1727
  const isError = block.is_error === true;
1689
- let output = '';
1690
- if (typeof block.content === 'string') {
1691
- output = block.content;
1692
- }
1693
- else if (Array.isArray(block.content)) {
1694
- output = block.content
1695
- .filter((c) => c.type === 'text')
1696
- .map((c) => c.text || '')
1697
- .join('\n');
1698
- }
1728
+ const output = typeof block.content === 'string'
1729
+ ? block.content
1730
+ : Array.isArray(block.content)
1731
+ ? block.content.filter((c) => c.type === 'text').map((c) => c.text || '').join('\n')
1732
+ : '';
1699
1733
  if (isError) {
1700
- events.push({ type: 'error', agent: 'droid', timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
1734
+ events.push({ type: 'error', agent, timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
1701
1735
  }
1702
1736
  else {
1703
1737
  events.push({
1704
- type: 'tool_result',
1705
- agent: 'droid',
1706
- timestamp,
1707
- tool: toolInfo?.tool,
1708
- success: true,
1738
+ type: 'tool_result', agent, timestamp, tool: toolInfo?.tool, success: true,
1709
1739
  output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1710
1740
  });
1711
1741
  }
@@ -1715,9 +1745,18 @@ export function parseDroid(filePath) {
1715
1745
  else if (block.type === 'image') {
1716
1746
  const source = block.source || {};
1717
1747
  const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
1718
- events.push(normalizedAttachmentEvent('droid', timestamp, block, source, 'image/png', sizeBytes));
1748
+ events.push(normalizedAttachmentEvent(agent, timestamp, block, source, 'image/png', sizeBytes));
1719
1749
  }
1720
1750
  }
1721
1751
  }
1722
1752
  return events;
1723
1753
  }
1754
+ /**
1755
+ * Parse a Droid (Factory) JSONL session file into normalized events. Droid
1756
+ * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
1757
+ * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
1758
+ * tool_result), so block handling mirrors the Claude parser.
1759
+ */
1760
+ export function parseDroid(filePath) {
1761
+ return parseAnthropicMessageJsonl(filePath, 'droid');
1762
+ }
Binary file
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
- export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
10
+ export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid' | 'cursor';
11
11
  /** All agents with session discovery support, in display order. */
12
12
  export declare const SESSION_AGENTS: SessionAgentId[];
13
13
  /**
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
- export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
10
+ export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid', 'cursor'];
11
11
  /**
12
12
  * True when `agent` stores session data `agents sessions` can discover (a member
13
13
  * of {@link SESSION_AGENTS}). The single predicate every session-index writer
@@ -1,13 +1,14 @@
1
1
  /**
2
- * Commands detector — mirrors versions.ts:343-357. Inspects the version home,
2
+ * Commands detector — mirrors the command dispatch in versions.ts. Inspects the version home,
3
3
  * returns command names. Honors the commands-as-skills marker for skills-only
4
- * agents (kimi, Codex >= 0.117.0, …); falls back to scanning
5
- * `{agentDir}/<commandsSubdir>/` for the native path.
4
+ * agents (Kimi, Codex >= 0.117.0, …), treats the native file as authoritative
5
+ * for dual-write targets (the skill copy is deliberately absent on a name
6
+ * collision), and scans `{agentDir}/<commandsSubdir>/` for native-only targets.
6
7
  */
7
8
  import * as fs from 'fs';
8
9
  import * as path from 'path';
9
10
  import { AGENTS, MANAGED_AGENT_IDS, agentConfigDirName } from '../../agents.js';
10
- import { shouldInstallCommandAsSkill, listCommandSkillsInVersion } from '../../command-skills.js';
11
+ import { listCommandSkillsInVersion, shouldInstallCommandAsSkill, } from '../../command-skills.js';
11
12
  import { lazyAgentMap } from '../writers/lazy-map.js';
12
13
  function buildCommandsDetector(agent) {
13
14
  return {
@@ -23,9 +24,17 @@ function buildCommandsDetector(agent) {
23
24
  if (!fs.existsSync(commandsDir))
24
25
  return [];
25
26
  const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
26
- return fs.readdirSync(commandsDir)
27
+ const nativeCommands = fs.readdirSync(commandsDir)
27
28
  .filter(f => f.endsWith(ext))
28
29
  .map(f => f.replace(new RegExp(`\\${ext}$`), ''));
30
+ // For a dual-write target the native file is the authoritative record that
31
+ // the command synced. The skill copy is derived, and
32
+ // installCommandSkillToVersion deliberately writes none when a real skill
33
+ // source already owns the name -- requiring both copies reported those
34
+ // commands missing forever and drove an `agents refresh` loop no sync could
35
+ // clear. This also matches `agents doctor`/`prune`, which read the
36
+ // unfiltered listCommandsInVersionHome.
37
+ return nativeCommands;
29
38
  },
30
39
  };
31
40
  }
@@ -44,6 +44,8 @@ export interface SyncManifest {
44
44
  v: typeof MANIFEST_VERSION;
45
45
  syncedAt: string;
46
46
  commands: Record<string, FileEntry>;
47
+ /** Command names the version writer emitted during the preceding full sync. */
48
+ writtenCommands?: string[];
47
49
  skills: Record<string, DirEntry>;
48
50
  hooks: Record<string, FileEntry>;
49
51
  rules: RulesEntry;