@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.
- package/CHANGELOG.md +60 -0
- package/README.md +3 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/routines.js +2 -2
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/ssh.js +3 -3
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +19 -1
- package/dist/lib/daemon.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +41 -8
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/runner.d.ts +14 -1
- package/dist/lib/runner.js +37 -8
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +21 -27
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
package/dist/lib/sandbox.js
CHANGED
|
@@ -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);
|
|
Binary file
|
|
Binary file
|
|
@@ -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 (
|
|
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
|
|
325
|
-
*
|
|
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
|
|
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 (
|
|
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
|
|
388
|
-
*
|
|
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
|
|
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)
|
|
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
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
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
|
-
|
|
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
|
|
1646
|
-
|
|
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
|
|
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
|
|
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
|
|
1660
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 (
|
|
5
|
-
*
|
|
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,
|
|
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
|
-
|
|
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;
|