@phnx-labs/agents-cli 1.20.87 → 1.20.89

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 (94) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +12 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/doctor.d.ts +0 -19
  6. package/dist/commands/doctor.js +219 -305
  7. package/dist/commands/exec.js +7 -19
  8. package/dist/commands/factory.js +26 -2
  9. package/dist/commands/funnel.js +16 -1
  10. package/dist/commands/inspect.js +3 -5
  11. package/dist/commands/menubar.js +117 -34
  12. package/dist/commands/routines.js +25 -3
  13. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  14. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  15. package/dist/commands/secrets.js +2 -0
  16. package/dist/commands/sessions.d.ts +7 -1
  17. package/dist/commands/sessions.js +40 -12
  18. package/dist/commands/ssh.js +3 -3
  19. package/dist/commands/usage.d.ts +3 -2
  20. package/dist/commands/usage.js +2 -9
  21. package/dist/commands/webhook.js +7 -2
  22. package/dist/lib/agents.d.ts +31 -1
  23. package/dist/lib/agents.js +55 -0
  24. package/dist/lib/command-skills.d.ts +10 -0
  25. package/dist/lib/command-skills.js +14 -0
  26. package/dist/lib/commands.js +28 -2
  27. package/dist/lib/daemon.d.ts +29 -0
  28. package/dist/lib/daemon.js +75 -6
  29. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  30. package/dist/lib/devices/doctor-findings.js +893 -0
  31. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  32. package/dist/lib/devices/fleet-divergence.js +34 -10
  33. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  34. package/dist/lib/devices/fleet-inventory.js +56 -8
  35. package/dist/lib/events.d.ts +1 -1
  36. package/dist/lib/exec.d.ts +14 -3
  37. package/dist/lib/exec.js +41 -8
  38. package/dist/lib/factory/snapshot.d.ts +78 -0
  39. package/dist/lib/factory/snapshot.js +209 -0
  40. package/dist/lib/fs-atomic.d.ts +14 -1
  41. package/dist/lib/fs-atomic.js +35 -3
  42. package/dist/lib/funnel.d.ts +1 -0
  43. package/dist/lib/funnel.js +8 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  47. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  48. package/dist/lib/menubar/install-menubar.js +183 -28
  49. package/dist/lib/platform/process.d.ts +2 -0
  50. package/dist/lib/platform/process.js +5 -3
  51. package/dist/lib/project-resources.js +34 -20
  52. package/dist/lib/resources.d.ts +8 -0
  53. package/dist/lib/resources.js +34 -1
  54. package/dist/lib/routines-placement.d.ts +2 -1
  55. package/dist/lib/routines-placement.js +8 -4
  56. package/dist/lib/routines.d.ts +57 -1
  57. package/dist/lib/routines.js +74 -1
  58. package/dist/lib/runner.d.ts +16 -1
  59. package/dist/lib/runner.js +58 -16
  60. package/dist/lib/sandbox.d.ts +2 -0
  61. package/dist/lib/sandbox.js +38 -0
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +9 -34
  65. package/dist/lib/secrets/filestore.d.ts +152 -34
  66. package/dist/lib/secrets/filestore.js +676 -123
  67. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  68. package/dist/lib/secrets/rc-hygiene.js +0 -24
  69. package/dist/lib/session/active.d.ts +6 -6
  70. package/dist/lib/session/active.js +6 -6
  71. package/dist/lib/session/discover.d.ts +5 -0
  72. package/dist/lib/session/discover.js +137 -1
  73. package/dist/lib/session/parse.d.ts +2 -0
  74. package/dist/lib/session/parse.js +76 -37
  75. package/dist/lib/session/remote-active.d.ts +4 -1
  76. package/dist/lib/session/remote-active.js +8 -2
  77. package/dist/lib/session/sync/agents.js +0 -0
  78. package/dist/lib/session/types.d.ts +1 -1
  79. package/dist/lib/session/types.js +1 -1
  80. package/dist/lib/session/viewing-in.d.ts +31 -0
  81. package/dist/lib/session/viewing-in.js +47 -0
  82. package/dist/lib/staleness/detectors/commands.js +14 -5
  83. package/dist/lib/staleness/types.d.ts +2 -0
  84. package/dist/lib/staleness/writers/commands.js +13 -7
  85. package/dist/lib/state.d.ts +17 -0
  86. package/dist/lib/state.js +30 -2
  87. package/dist/lib/triggers/handlers.d.ts +95 -0
  88. package/dist/lib/triggers/handlers.js +384 -0
  89. package/dist/lib/triggers/webhook.d.ts +10 -2
  90. package/dist/lib/triggers/webhook.js +65 -11
  91. package/dist/lib/usage.d.ts +72 -1
  92. package/dist/lib/usage.js +21 -27
  93. package/dist/lib/versions.js +30 -13
  94. package/package.json +1 -1
@@ -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
+ }
@@ -9,7 +9,10 @@ export declare const NO_FANOUT_ENV = "AGENTS_SESSIONS_LOCAL";
9
9
  * Parse a peer's `--active --json` stdout into active sessions, tagging each
10
10
  * with `machine`. Defensive against version skew / partial output: non-JSON or
11
11
  * a non-array yields `[]`, and non-object entries are dropped rather than
12
- * throwing. Exported for unit testing without a live tailnet.
12
+ * throwing. `viewingIn` arrives as a display string from a current peer and as
13
+ * an `{app, tab}` object from one that predates the flattening, so it is
14
+ * normalized here — the single boundary where a foreign row becomes internal.
15
+ * Exported for unit testing without a live tailnet.
13
16
  */
14
17
  export declare function parseRemoteActive(stdout: string, machine: string): ActiveSession[];
15
18
  export interface RemoteActiveResult {
@@ -15,6 +15,7 @@
15
15
  * `spawn`, not the sync `sshExec`) so N peers cost one round-trip, not N.
16
16
  */
17
17
  import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
18
+ import { parseViewingIn } from './viewing-in.js';
18
19
  /**
19
20
  * Recursion guard, passed as an env var (not a CLI flag) so an OLDER remote
20
21
  * `agents` that predates this feature ignores it harmlessly instead of erroring
@@ -25,7 +26,10 @@ export const NO_FANOUT_ENV = 'AGENTS_SESSIONS_LOCAL';
25
26
  * Parse a peer's `--active --json` stdout into active sessions, tagging each
26
27
  * with `machine`. Defensive against version skew / partial output: non-JSON or
27
28
  * a non-array yields `[]`, and non-object entries are dropped rather than
28
- * throwing. Exported for unit testing without a live tailnet.
29
+ * throwing. `viewingIn` arrives as a display string from a current peer and as
30
+ * an `{app, tab}` object from one that predates the flattening, so it is
31
+ * normalized here — the single boundary where a foreign row becomes internal.
32
+ * Exported for unit testing without a live tailnet.
29
33
  */
30
34
  export function parseRemoteActive(stdout, machine) {
31
35
  let parsed;
@@ -40,7 +44,9 @@ export function parseRemoteActive(stdout, machine) {
40
44
  const out = [];
41
45
  for (const x of parsed) {
42
46
  if (x && typeof x === 'object' && !Array.isArray(x)) {
43
- out.push({ ...x, machine });
47
+ const row = { ...x, machine };
48
+ row.viewingIn = parseViewingIn(x.viewingIn);
49
+ out.push(row);
44
50
  }
45
51
  }
46
52
  return out;
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
@@ -27,6 +27,37 @@ export interface ViewingIn {
27
27
  /** 1-based tab number within that app, when it can be resolved. */
28
28
  tab?: number;
29
29
  }
30
+ /**
31
+ * The one display form of "where is this session being watched" — `'codium tab 3'`
32
+ * when a client is attached, the bare app name when the tab can't be resolved, and
33
+ * `'detached'` when the pane is live but nobody is looking at it (the terminal that
34
+ * displayed it was closed or crashed).
35
+ *
36
+ * `undefined` means **we do not know**, and it covers two different situations that
37
+ * must not be confused with `'detached'`: a session that isn't tmux-hosted (no pane
38
+ * to attach to, so it isn't on this axis at all), and a tmux session whose pane the
39
+ * locator could not resolve (`mapPanesToTargets` returned nothing for this socket,
40
+ * or the row was never enriched). {@link resolveViewingIn} answers `undefined` for
41
+ * BOTH "no client attached" and "could not locate the pane", so the pane's resolved
42
+ * `tmuxTarget` is what separates them: without it there is no evidence of absence,
43
+ * only absence of evidence. Claiming `'detached'` there would invent an orphaned
44
+ * session — and a consumer acts on that claim (Factory's picker pre-ticks every
45
+ * detached row for rescue), so the wrong answer resumes a session nobody asked for.
46
+ *
47
+ * Shared by the `--active` row renderer and the `--json` serializer so a machine
48
+ * consumer reads exactly the string a human sees, instead of re-deriving "detached"
49
+ * from an absent field.
50
+ */
51
+ export declare function viewingInLabel(s: Pick<ActiveSession, 'provenance' | 'viewingIn' | 'tmuxTarget'>): string | undefined;
52
+ /**
53
+ * Wire form -> internal form for a row arriving from another machine's
54
+ * `--active --json`. The fan-out reaches peers whose CLI may predate
55
+ * {@link viewingInLabel} and still emit the `{app, tab}` object, so this boundary
56
+ * normalizes both shapes into one — the ONLY place either shape is accepted.
57
+ * `'detached'` maps to undefined, which is what "no attached client" means
58
+ * internally; {@link viewingInLabel} regenerates the word from the pane.
59
+ */
60
+ export declare function parseViewingIn(raw: unknown): ViewingIn | undefined;
30
61
  /** Injection seams so `resolveViewingIn` is unit-testable without a live tmux/ps/osascript. */
31
62
  export interface ViewingInDeps {
32
63
  /** Ghostty surfaces (window/tab/cwd/title). Enumerated once by the caller and shared. */