diff --git a/CLAUDE.md b/CLAUDE.md index 3f2756b..5728ab0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,16 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Agent Quest is a browser-based monitoring dashboard that visualizes active Claude Code and Codex agent sessions as fantasy heroes in a 2D WoW-style village. Each agent is represented as a hero character that walks between buildings corresponding to its current activity (Read → Library, Edit → Forge, Bash → Arena, etc.). +Agent Quest is a browser-based monitoring dashboard that visualizes active Claude Code, Codex and Cursor agent sessions as fantasy heroes in a 2D WoW-style village. Each agent is represented as a hero character that walks between buildings corresponding to its current activity (Read → Library, Edit → Forge, Bash → Arena, etc.). ## Architecture Two-process monorepo: -- **server/** — Bun + Hono backend. Two providers run in parallel: `ClaudeProvider` auto-discovers every `~/.claude*` directory with a `projects/` subdir (e.g. `~/.claude`, `~/.claude-work`, `~/.claude-personale`); `CodexProvider` watches `~/.codex/sessions/` for Codex rollout files. Both poll their session logs every 2-3s, parse events into `AgentState` objects, push updates over native Bun WebSocket. Each `AgentState` carries its `configDir` and a `source` field (`'claude' | 'codex'`) so the UI can distinguish installations and providers. Optional Hono endpoint receives Claude Code `postToolUse` hooks for lower-latency events — **Claude Code only**; Codex doesn't expose hooks. `SessionRegistry` (pidfile oracle) is also **Claude-only by design**; Codex liveness is inferred purely from rollout-file activity. +- **server/** — Bun + Hono backend. Three providers run in parallel: `ClaudeProvider` auto-discovers every `~/.claude*` directory with a `projects/` subdir (e.g. `~/.claude`, `~/.claude-work`, `~/.claude-personale`); `CodexProvider` watches `~/.codex/sessions/` for Codex rollout files; `CursorProvider` watches `~/.cursor/projects/**/agent-transcripts/**/*.jsonl` for Cursor IDE agent-chat transcripts. All poll their session logs every 2-3s, parse events into `AgentState` objects, push updates over native Bun WebSocket. Each `AgentState` carries its `configDir` and a `source` field (`'claude' | 'codex' | 'cursor'`) so the UI can distinguish installations and providers. Optional Hono endpoint receives Claude Code `postToolUse` hooks for lower-latency events — **Claude Code only**; Codex and Cursor don't expose hooks. `SessionRegistry` (pidfile oracle) is also **Claude-only by design**; Codex and Cursor liveness is inferred purely from log-file activity. - **client/** — React 19 + Phaser 4 "Caladan" frontend. Fullscreen Phaser canvas renders the village; React overlay panels (Party Bar, Activity Feed, Detail Panel, Minimap, Top Bar) sit on top via ref-based bridge pattern (useRef + useEffect + EventEmitter). -Data flow: `~/.claude*/projects/**/*.jsonl` and `~/.codex/sessions/**/rollout-*.jsonl` → ClaudeProvider / CodexProvider → SessionParser (per-format) → AgentStateManager → WebSocket → Browser (React state + Phaser scene). +Data flow: `~/.claude*/projects/**/*.jsonl`, `~/.codex/sessions/**/rollout-*.jsonl` and `~/.cursor/projects//agent-transcripts/**/*.jsonl` → ClaudeProvider / CodexProvider / CursorProvider → SessionParser (per-format) → AgentStateManager → WebSocket → Browser (React state + Phaser scene). ## Commands @@ -45,7 +45,7 @@ These are fixed. Do NOT use 3000, 3333, 5173, 5174, 8000 — reserved by other p ## Key Type: AgentState -The central data model flows from server to client. Defined in shared types. Maps tool calls to activities: Read/Grep/Glob → `reading`, Edit/Write → `editing`, Bash → `bash`, thinking → `thinking`, git → `git`, idle → `idle`, debug → `debugging`, review → `reviewing`. `configDir` can be `~/.claude*` or `~/.codex`; the `source` field (`'claude' | 'codex'`) discriminates which provider produced the session. +The central data model flows from server to client. Defined in shared types. Maps tool calls to activities: Read/Grep/Glob → `reading`, Edit/Write → `editing`, Bash → `bash`, thinking → `thinking`, git → `git`, idle → `idle`, debug → `debugging`, review → `reviewing`. `configDir` can be `~/.claude*`, `~/.codex` or `~/.cursor`; the `source` field (`'claude' | 'codex' | 'cursor'`) discriminates which provider produced the session. ## Design Spec diff --git a/README.md b/README.md index 2643dc4..a24408e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- A fantasy village dashboard for monitoring your Claude Code CLI and Codex agents. + A fantasy village dashboard for monitoring your Claude Code, Codex and Cursor agents.

@@ -14,9 +14,9 @@ --- -> **Use Claude Code CLI or Codex as usual — each agent session auto-spawns a hero on the dashboard, live.** +> **Use Claude Code, Codex or Cursor as usual — each agent session auto-spawns a hero on the dashboard, live.** -Agent Quest is a browser-based monitoring dashboard that visualizes active Claude Code and Codex agent sessions as fantasy heroes in a 2D village. Each running agent becomes a hero who walks between buildings based on what it's doing: `Read` sends it to the Library, `Edit` to the Forge, `Bash` to the Arena, and so on. +Agent Quest is a browser-based monitoring dashboard that visualizes active Claude Code, Codex and Cursor agent sessions as fantasy heroes in a 2D village. Each running agent becomes a hero who walks between buildings based on what it's doing: `Read` sends it to the Library, `Edit` to the Forge, `Bash` to the Arena, and so on.

Agent Quest — main view @@ -41,21 +41,21 @@ Agent Quest is a browser-based monitoring dashboard that visualizes active Claud ## Why? -Claude Code and Codex sessions happen in a terminal — useful, but not very *alive*. When you run several agents at once (across projects, across `~/.claude*` installations and `~/.codex`), it's hard to feel what they're actually doing. Agent Quest turns that invisible activity into something you can glance at: a little village where every hero is an agent, and where they walk tells you what they're up to. +Claude Code, Codex and Cursor sessions happen in a terminal — useful, but not very *alive*. When you run several agents at once (across projects, across `~/.claude*` installations, `~/.codex` and `~/.cursor`), it's hard to feel what they're actually doing. Agent Quest turns that invisible activity into something you can glance at: a little village where every hero is an agent, and where they walk tells you what they're up to. ## Features -- Real-time visualization of active Claude Code and Codex sessions -- Auto-discovery of every `~/.claude*` directory (supports multiple installations like `~/.claude-work`, `~/.claude-personale`) and of `~/.codex` if present +- Real-time visualization of active Claude Code, Codex and Cursor sessions +- Auto-discovery of every `~/.claude*` directory (supports multiple installations like `~/.claude-work`, `~/.claude-personale`), of `~/.codex` and of `~/.cursor` if present - Activity feed, party bar, and detail panel alongside the village scene - Built-in map editor for customizing the village layout -- Sub-2s latency via native WebSocket (optional lower-latency path via Claude Code `postToolUse` hooks — Claude Code only; Codex doesn't expose hooks) +- Sub-2s latency via native WebSocket (optional lower-latency path via Claude Code `postToolUse` hooks — Claude Code only; Codex and Cursor don't expose hooks) ## Requirements **Required** - [Bun](https://bun.sh) 1.1 or later — the runtime behind both the server and the scripts. If you don't have it: `curl -fsSL https://bun.sh/install | bash` -- An active Claude Code or Codex installation (one or more `~/.claude*` directories, and/or `~/.codex`, with session logs). Without either, the dashboard still starts, but the village stays empty and a banner tells you so. +- An active Claude Code, Codex or Cursor installation (one or more `~/.claude*` directories, and/or `~/.codex`, and/or `~/.cursor`, with session logs). Without any of them, the dashboard still starts, but the village stays empty and a banner tells you so. - See the [Platform matrix](#platform-matrix) below for OS support per provider. **Optional** @@ -144,7 +144,7 @@ lsof -ti:4444,4445 | xargs kill -9 echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc ``` -**Empty village with a "No Claude Code or Codex installation detected" banner** — expected when no `~/.claude*` or `~/.codex` directory with session logs exists. Start a Claude Code or Codex session and heroes appear automatically (the banner disappears on its own). +**Empty village with a "No Claude Code, Codex or Cursor installation detected" banner** — expected when no `~/.claude*`, `~/.codex` or `~/.cursor` directory with session logs exists. Start a Claude Code, Codex or Cursor session and heroes appear automatically (the banner disappears on its own). **Assets look broken or the app blocks at boot with "missing asset" screens** — see [Missing assets](#missing-assets). @@ -224,8 +224,9 @@ Open the UI URL on the other device. The client auto-detects the host so API and |-------------|-------|----------------------|-------| | Claude Code | ✓ | ✓ (WSL2 recommended) | ✓ | | Codex | ✓ | not yet verified | not yet verified | +| Cursor | ✓ | not yet verified | not yet verified | -Claude Code is exercised on macOS and Windows (via WSL2). Codex has been tested on macOS only so far — it should work on Windows/Linux the same way (the provider watches `~/.codex/sessions/`), but we haven't confirmed it yet. +Claude Code is exercised on macOS and Windows (via WSL2). Codex has been tested on macOS only so far — it should work on Windows/Linux the same way (the provider watches `~/.codex/sessions/`), but we haven't confirmed it yet. Cursor support reads the IDE's local agent-chat transcripts (`~/.cursor/projects/`) and has been tested on macOS only so far. ## Windows diff --git a/bin/agentquest b/bin/agentquest index 20c4a65..4f5e05d 100755 --- a/bin/agentquest +++ b/bin/agentquest @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# agentquest — local dashboard for Claude Code and Codex agents +# agentquest — local dashboard for Claude Code, Codex and Cursor agents # macOS-first; relies on bash 3.2+, git, bun >= 1.1. set -euo pipefail @@ -128,7 +128,7 @@ prompt_yn() { # ------------------------------------------------------------------ sub-commands cmd_help() { cat < a.id === selectedAgentId) ?? null : null; - // Only show source badges when both providers have a LIVE agent — completed - // / error sessions don't count, otherwise the badge would linger after the - // last Codex hero finishes just because it's still in state. - const liveAgents = agents.filter((a) => a.status !== 'completed' && a.status !== 'error'); - const showSourceBadge = liveAgents.some((a) => a.source === 'claude') - && liveAgents.some((a) => a.source === 'codex'); + // Only show source badges when two or more providers have a LIVE agent — + // completed / error / parked waiting-idle sessions don't count. + const liveAgents = agents.filter((a) => isLiveRosterAgent(a)); + const liveSources = new Set(liveAgents.map((a) => a.source)); + const showSourceBadge = liveSources.size >= 2; // When selecting agent, clear building const handleSelectAgent = useCallback((id: string | null) => { diff --git a/client/src/agentVisibility.test.ts b/client/src/agentVisibility.test.ts new file mode 100644 index 0000000..8aef7cb --- /dev/null +++ b/client/src/agentVisibility.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'bun:test'; +import { PARK_AFTER_MS, isParkedAgent, isLiveRosterAgent, isVillageVisibleAgent } from './agentVisibility'; + +const now = 1_000_000_000; + +function agent(over: { status: 'active' | 'waiting' | 'idle' | 'completed' | 'error'; lastEvent: number }) { + return over; +} + +describe('agentVisibility', () => { + it('parks waiting and idle after PARK_AFTER_MS, never parks active', () => { + expect(isParkedAgent(agent({ status: 'waiting', lastEvent: now - PARK_AFTER_MS - 1 }), now)).toBe(true); + expect(isParkedAgent(agent({ status: 'idle', lastEvent: now - PARK_AFTER_MS - 1 }), now)).toBe(true); + expect(isParkedAgent(agent({ status: 'waiting', lastEvent: now - 60_000 }), now)).toBe(false); + expect(isParkedAgent(agent({ status: 'active', lastEvent: now - PARK_AFTER_MS * 2 }), now)).toBe(false); + expect(isParkedAgent(agent({ status: 'completed', lastEvent: now - PARK_AFTER_MS * 2 }), now)).toBe(false); + }); + + it('keeps recent waiting/idle on the live roster', () => { + expect(isLiveRosterAgent(agent({ status: 'active', lastEvent: now }), now)).toBe(true); + expect(isLiveRosterAgent(agent({ status: 'waiting', lastEvent: now - 60_000 }), now)).toBe(true); + expect(isLiveRosterAgent(agent({ status: 'idle', lastEvent: now - 60_000 }), now)).toBe(true); + expect(isLiveRosterAgent(agent({ status: 'waiting', lastEvent: now - PARK_AFTER_MS - 1 }), now)).toBe(false); + expect(isLiveRosterAgent(agent({ status: 'completed', lastEvent: now }), now)).toBe(false); + expect(isLiveRosterAgent(agent({ status: 'error', lastEvent: now }), now)).toBe(false); + }); + + it('hides parked waiting/idle from the village along with completed/error', () => { + expect(isVillageVisibleAgent(agent({ status: 'waiting', lastEvent: now - 60_000 }), now)).toBe(true); + expect(isVillageVisibleAgent(agent({ status: 'waiting', lastEvent: now - PARK_AFTER_MS - 1 }), now)).toBe(false); + expect(isVillageVisibleAgent(agent({ status: 'idle', lastEvent: now - PARK_AFTER_MS - 1 }), now)).toBe(false); + expect(isVillageVisibleAgent(agent({ status: 'completed', lastEvent: now }), now)).toBe(false); + expect(isVillageVisibleAgent(agent({ status: 'error', lastEvent: now }), now)).toBe(false); + }); +}); diff --git a/client/src/agentVisibility.ts b/client/src/agentVisibility.ts new file mode 100644 index 0000000..5c7ebbe --- /dev/null +++ b/client/src/agentVisibility.ts @@ -0,0 +1,32 @@ +import type { AgentState } from './types/agent'; + +/** + * Waiting / idle agents older than this leave the live roster (Party Bar, + * village, Top Bar counts, source badges). Matches CursorProvider's + * `endedQuietMs` so a finished turn that we still ingest doesn't linger + * as a waiting hero. + */ +export const PARK_AFTER_MS = 15 * 60 * 1000; + +type StatusAndLastEvent = Pick; + +/** Waiting or idle with no events for longer than {@link PARK_AFTER_MS}. */ +export function isParkedAgent(agent: StatusAndLastEvent, now = Date.now()): boolean { + if (agent.status !== 'waiting' && agent.status !== 'idle') return false; + return now - agent.lastEvent > PARK_AFTER_MS; +} + +/** Active, plus waiting/idle that are still within the park window. */ +export function isLiveRosterAgent(agent: StatusAndLastEvent, now = Date.now()): boolean { + if (agent.status === 'active') return true; + if (agent.status === 'waiting' || agent.status === 'idle') { + return !isParkedAgent(agent, now); + } + return false; +} + +/** Heroes that should walk the village (live roster minus completed/error). */ +export function isVillageVisibleAgent(agent: StatusAndLastEvent, now = Date.now()): boolean { + if (agent.status === 'completed' || agent.status === 'error') return false; + return isLiveRosterAgent(agent, now); +} diff --git a/client/src/components/ActivityFeed.tsx b/client/src/components/ActivityFeed.tsx index fa8f99c..fb34fd3 100644 --- a/client/src/components/ActivityFeed.tsx +++ b/client/src/components/ActivityFeed.tsx @@ -170,7 +170,7 @@ export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent, show {filtered.length === 0 ? (

Waiting for agent activity...
-
Launch Claude Code or Codex in any project — it'll appear here.
+
Launch Claude Code, Codex or Cursor in any project — it'll appear here.
) : ( filtered.map((entry) => { diff --git a/client/src/components/BuildingInfoPanel.tsx b/client/src/components/BuildingInfoPanel.tsx index f5a45c7..27dd9ca 100644 --- a/client/src/components/BuildingInfoPanel.tsx +++ b/client/src/components/BuildingInfoPanel.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef, useState } from 'react'; import type { AgentState } from '../types/agent'; +import { isLiveRosterAgent } from '../agentVisibility'; import { BUILDING_DEFS } from '../game/data/building-layout'; import './BuildingInfoPanel.css'; @@ -45,7 +46,8 @@ export function BuildingInfoPanel({ buildingId, anchor, agents, onClose }: Build if (building === undefined) return null; const agentsHere = agents.filter( - (a) => a.currentActivity === building.activity && (a.status === 'active' || a.status === 'idle'), + (a) => a.currentActivity === building.activity && isLiveRosterAgent(a) + && (a.status === 'active' || a.status === 'idle'), ); const style = position === null diff --git a/client/src/components/Minimap.tsx b/client/src/components/Minimap.tsx index d0a8543..320016f 100644 --- a/client/src/components/Minimap.tsx +++ b/client/src/components/Minimap.tsx @@ -1,5 +1,6 @@ import { useRef, useEffect } from 'react'; import type { AgentState } from '../types/agent'; +import { isLiveRosterAgent } from '../agentVisibility'; import { BUILDING_DEFS, VILLAGE_GATE } from '../game/data/building-layout'; import './Minimap.css'; @@ -54,7 +55,9 @@ export function Minimap({ agents }: MinimapProps) { ctx.lineWidth = 1; ctx.strokeRect((VILLAGE_GATE.x - 15) * sx, (VILLAGE_GATE.y - 8) * sy, 30 * sx, 16 * sy); - const visible = agents.filter((a) => a.status === 'active' || a.status === 'idle'); + const visible = agents.filter((a) => + isLiveRosterAgent(a) && (a.status === 'active' || a.status === 'idle'), + ); for (const agent of visible) { const building = BUILDING_DEFS.find((b) => b.activity === agent.currentActivity); if (building === undefined) continue; diff --git a/client/src/components/NoInstallBanner.tsx b/client/src/components/NoInstallBanner.tsx index e9efbcf..d5ec9a0 100644 --- a/client/src/components/NoInstallBanner.tsx +++ b/client/src/components/NoInstallBanner.tsx @@ -12,7 +12,7 @@ interface Props { * Full-width banner shown when the WebSocket is connected AND the server * reported zero config dirs across both providers. Disambiguates the two * empty-village states: - * - "Neither Claude Code nor Codex is installed" → this banner + * - "Neither Claude Code, Codex nor Cursor is installed" → this banner * - "At least one provider is installed but idle" → no banner * Dismiss is persisted (with one-time migration from the old Claude-only key) * so returning users don't see it every load. @@ -43,10 +43,10 @@ export function NoInstallBanner({ configDirs, connected }: Props) {
-
No Claude Code or Codex installation detected
+
No Claude Code, Codex or Cursor installation detected
- The server found no ~/.claude* or ~/.codex directory with session logs. - {' '}Start a Claude Code or Codex session to see heroes appear here. + The server found no ~/.claude*, ~/.codex or ~/.cursor directory with session logs. + {' '}Start a Claude Code, Codex or Cursor session to see heroes appear here.
- {completed.length > 0 && ( + {away.length > 0 && (
{showCompleted && (
- {completed.map((agent) => ( + {away.map((agent) => ( )} - {/* Tokens — Claude only. Codex doesn't report usage, so the section is - omitted entirely for it (no misleading empty rows). */} + {/* Tokens — Claude only. Codex and Cursor don't report usage, so the + section is omitted entirely for them (no misleading empty rows). */} {r.hasTokens ? ( <>
Tokens
diff --git a/client/src/components/TopBar.tsx b/client/src/components/TopBar.tsx index b7dca21..0ff4d84 100644 --- a/client/src/components/TopBar.tsx +++ b/client/src/components/TopBar.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import type { AgentState } from '../types/agent'; import { eventBridge } from '../game/EventBridge'; import { NotificationMenu, type NotificationEntry } from './NotificationMenu'; +import { isLiveRosterAgent, isParkedAgent } from '../agentVisibility'; import './TopBar.css'; interface TopBarNotifications { @@ -21,9 +22,9 @@ interface TopBarProps { export function TopBar({ agents, connected, notifications }: TopBarProps) { const active = agents.filter((a) => a.status === 'active').length; - const waiting = agents.filter((a) => a.status === 'waiting').length; - const idle = agents.filter((a) => a.status === 'idle').length; - const completed = agents.filter((a) => a.status === 'completed').length; + const waiting = agents.filter((a) => a.status === 'waiting' && isLiveRosterAgent(a)).length; + const idle = agents.filter((a) => a.status === 'idle' && isLiveRosterAgent(a)).length; + const completed = agents.filter((a) => a.status === 'completed' || isParkedAgent(a)).length; const errors = agents.filter((a) => a.status === 'error').length; const [nightOn, setNightOn] = useState(false); diff --git a/client/src/components/Tutorial.tsx b/client/src/components/Tutorial.tsx index c92d00d..3daa64f 100644 --- a/client/src/components/Tutorial.tsx +++ b/client/src/components/Tutorial.tsx @@ -33,7 +33,7 @@ export function Tutorial({ onClose }: TutorialProps) {

Welcome to Agent Quest

- Use Claude Code CLI or Codex as usual — each session auto-spawns a hero on the dashboard, live. + Use Claude Code, Codex or Cursor as usual — each session auto-spawns a hero on the dashboard, live.
Agent Quest is a live dashboard that turns your{' '} - Claude Code and Codex sessions into a 2D + Claude Code, Codex and Cursor sessions into a 2D fantasy village. Every running agent becomes a hero who walks between buildings based on what it's doing.

@@ -70,8 +70,9 @@ export function Tutorial({ onClose }: TutorialProps) { rel="noreferrer" >Codex
, reading rollout logs from{' '} - ~/.codex/sessions/. It doesn't work with the Claude desktop - app or claude.ai in the browser, since those don't expose local session files. + ~/.codex/sessions/. It also works with Cursor, reading the + IDE's agent-chat transcripts from ~/.cursor/projects/. It doesn't work with + the Claude desktop app or claude.ai in the browser, since those don't expose local session files.

Activity → Building
@@ -88,14 +89,13 @@ export function Tutorial({ onClose }: TutorialProps) {

- When both Claude Code and Codex have active agents, each hero shows a small{' '} - CLAUDE or CODEX badge so you can tell which - CLI it came from. + When agents from more than one source are active, each hero shows a small{' '} + CLAUDE, CODEX or CURSOR badge so you can tell where it came from.

How to use it

- Heroes appear in real time as you spawn new Claude Code or Codex sessions. Click a + Heroes appear in real time as you spawn new Claude Code, Codex or Cursor sessions. Click a hero in the Party Bar (bottom) to see what it's doing; click any building to see which heroes are there. The 🗺️ icon in the top bar opens the map editor. Reopen this tutorial any time with the{' '} @@ -131,7 +131,7 @@ export function Tutorial({ onClose }: TutorialProps) {

Privacy

Everything runs locally on your machine. Nothing is uploaded, shared or - persisted — Agent Quest only reads the logs Claude Code and Codex already + persisted — Agent Quest only reads the logs Claude Code, Codex and Cursor already keep on your disk.

@@ -143,7 +143,10 @@ export function Tutorial({ onClose }: TutorialProps) { Codex: macOS tested. Windows not yet verified.

- Linux: should work for both, but not routinely tested. + Cursor: tested on macOS. Windows/Linux not yet verified. +

+

+ Linux: should work for all three, but not routinely tested.

About
diff --git a/client/src/components/configDirLabel.test.ts b/client/src/components/configDirLabel.test.ts index 09f1705..d5f7cc9 100644 --- a/client/src/components/configDirLabel.test.ts +++ b/client/src/components/configDirLabel.test.ts @@ -14,6 +14,10 @@ describe('configDirLabel', () => { expect(configDirLabel('/Users/foo/.codex')).toBe('codex'); }); + it('returns "cursor" for ~/.cursor', () => { + expect(configDirLabel('/Users/foo/.cursor')).toBe('cursor'); + }); + it('strips .claude- prefix for multi-installs', () => { expect(configDirLabel('/Users/foo/.claude-work')).toBe('work'); expect(configDirLabel('/Users/foo/.claude-personale')).toBe('personale'); diff --git a/client/src/components/configDirLabel.ts b/client/src/components/configDirLabel.ts index ac4faac..2005582 100644 --- a/client/src/components/configDirLabel.ts +++ b/client/src/components/configDirLabel.ts @@ -1,14 +1,15 @@ /** * Human-friendly label for an agent's source config directory. * Handles Claude Code multi-installs (`.claude`, `.claude-work`), - * Codex (`.codex`), and strips the leading dot for any other dotted - * directory so the UI doesn't render names like ".foo". + * Codex (`.codex`), Cursor (`.cursor`), and strips the leading dot for any + * other dotted directory so the UI doesn't render names like ".foo". */ export function configDirLabel(configDir: string): string { if (configDir === '') return 'default'; const base = configDir.split('/').pop() ?? configDir; if (base === '.claude') return 'claude'; if (base === '.codex') return 'codex'; + if (base === '.cursor') return 'cursor'; const stripped = base.replace(/^\.claude-?/, ''); if (stripped !== base) return stripped; return base.replace(/^\./, ''); diff --git a/client/src/game/entities/HeroSprite.ts b/client/src/game/entities/HeroSprite.ts index 6a6231e..23e7c6e 100644 --- a/client/src/game/entities/HeroSprite.ts +++ b/client/src/game/entities/HeroSprite.ts @@ -404,7 +404,7 @@ export class HeroSprite { } /** - * Show or hide the source badge (`CODEX` / `CLAUDE`). Called by the scene + * Show or hide the source badge (`CLAUDE` / `CODEX` / `CURSOR`). Called by the scene * whenever the fleet's provider makeup changes. Lazily creates the Text * object on first reveal. When a subagent marker is already present, the * two labels sit side-by-side on the subagent row; otherwise the badge sits diff --git a/client/src/game/scenes/VillageScene.ts b/client/src/game/scenes/VillageScene.ts index 2ef7fdd..bc6a248 100644 --- a/client/src/game/scenes/VillageScene.ts +++ b/client/src/game/scenes/VillageScene.ts @@ -13,6 +13,7 @@ import type { AssetManifest, MapConfig, BuildingPosition, NpcPlacement } from '. import { SERVER_URL as API_BASE } from '../../config'; import { getActiveTheme, rebaseSavedScale } from '../themes/registry'; import { sceneRenderScale } from '../dpr'; +import { isLiveRosterAgent, isVillageVisibleAgent } from '../../agentVisibility'; /** Set `cam.zoom` to `newZoom` while keeping the world point currently * under screen coordinates (sx, sy) pinned to the same screen spot. @@ -26,9 +27,6 @@ function zoomAroundPointer(cam: Phaser.Cameras.Scene2D.Camera, sx: number, sy: n cam.scrollY += before.y - after.y; } -/** Hide agents idle for longer than this from the Phaser scene (kept in PartyBar). */ -const IDLE_HIDE_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours - /** Grid spacing between heroes at the same building. */ const GRID_SPACING_X = 40; const GRID_SPACING_Y = 35; @@ -643,21 +641,15 @@ export class VillageScene extends Phaser.Scene { const now = Date.now(); - // Show active + idle-recent (< 2h), hide completed/error and idle > 2h - const visible = agents.filter((a) => { - if (a.status === 'completed' || a.status === 'error') return false; - if (a.status === 'idle' && now - a.lastEvent > IDLE_HIDE_THRESHOLD_MS) return false; - return true; - }); + // Show active + recent waiting/idle; hide completed/error and parked agents. + const visible = agents.filter((a) => isVillageVisibleAgent(a, now)); - // Mixed-provider mode: show source badges only when both Claude and Codex - // have a LIVE hero. Completed/error sessions don't count, so the badge - // disappears the moment the last non-dormant Codex hero finishes. Mirrors - // the flag computed in App.tsx — keep these two in sync. - const liveAgents = agents.filter((a) => a.status !== 'completed' && a.status !== 'error'); - const hasClaude = liveAgents.some((a) => a.source === 'claude'); - const hasCodex = liveAgents.some((a) => a.source === 'codex'); - const showSourceBadge = hasClaude && hasCodex; + // Mixed-provider mode: show source badges only when two or more providers + // have a LIVE hero. Completed/error/parked sessions don't count, so the badge + // disappears the moment the last non-dormant Codex/Cursor hero finishes. + // Mirrors the flag computed in App.tsx — keep these two in sync. + const liveAgents = agents.filter((a) => isLiveRosterAgent(a, now)); + const showSourceBadge = new Set(liveAgents.map((a) => a.source)).size >= 2; // Remove heroes no longer visible for (const [id, hero] of this.heroes) { diff --git a/client/src/hooks/useAgentState.ts b/client/src/hooks/useAgentState.ts index 992d8e9..f23883b 100644 --- a/client/src/hooks/useAgentState.ts +++ b/client/src/hooks/useAgentState.ts @@ -12,7 +12,7 @@ export interface AgentStateHook { connected: boolean; /** Config dirs reported by the server in the last snapshot. `null` means * we haven't received a snapshot yet (still connecting); an empty array - * means the server found neither ~/.claude* nor ~/.codex install on disk. */ + * means the server found no ~/.claude*, ~/.codex or ~/.cursor install on disk. */ configDirs: string[] | null; } diff --git a/client/src/types/agent.ts b/client/src/types/agent.ts index 81147d4..fa7ed85 100644 --- a/client/src/types/agent.ts +++ b/client/src/types/agent.ts @@ -12,7 +12,7 @@ export const HERO_COLORS = [ ] as const; export type HeroColor = (typeof HERO_COLORS)[number]; -export const AGENT_SOURCES = ['claude', 'codex'] as const; +export const AGENT_SOURCES = ['claude', 'codex', 'cursor'] as const; export type AgentSource = (typeof AGENT_SOURCES)[number]; /** @@ -24,6 +24,7 @@ export type AgentSource = (typeof AGENT_SOURCES)[number]; export const SOURCE_BADGE_COLOR: Record = { claude: '#FF9F4A', // orange codex: '#7ED9CF', // teal + cursor: '#7CB8F7', // sky blue }; /** diff --git a/docs/2026-08-24-cursor-provider-summary.md b/docs/2026-08-24-cursor-provider-summary.md new file mode 100644 index 0000000..dfb6f82 --- /dev/null +++ b/docs/2026-08-24-cursor-provider-summary.md @@ -0,0 +1,61 @@ +# Cursor provider — implementation summary + +**Date:** 2026-08-24 + +Cursor IDE agent chats are now a third data source alongside Claude Code and Codex. + +## What shipped + +### Server +- `server/src/parsers/cursor-parser.ts` — parses `~/.cursor/projects//agent-transcripts/**/*.jsonl` +- `server/src/providers/cursor-provider.ts` — polls those transcripts (3s), same lifecycle as Codex +- `AgentSource` extended to `'claude' | 'codex' | 'cursor'` +- Wired in `server/src/index.ts` + +Tool mapping: + +| Cursor tool | Village activity | +|---|---| +| Read, ReadFile, Grep, Glob, rg | reading | +| Write, StrReplace, Edit, ApplyPatch, Delete | editing | +| Shell / Bash | bash (git commit/push/… → git) | +| Task, Subagent | reviewing | +| everything else (MCP, TodoWrite, WebFetch…) | thinking | +| `turn_ended` | idle (turn end) | + +### Client +- Source badge color for Cursor (`#7CB8F7`) +- Badges appear when **≥ 2 distinct sources** are live (not only Claude+Codex) +- Empty-state banner, tutorial, activity feed, config-dir labels, README, CLAUDE.md, installer copy + +### Tests +- Server: **200 pass** (`cd server && bun test`) +- Client: **107 pass** (`cd client && bun test`) + +### Live check (this machine) +- Server discovered **13 Cursor sessions**, including this Agent-Quest chat (`ab1d17c7-…`, status `active`) +- UI at `http://localhost:4445`: Status Online, Active 4 / Waiting 9 / Total 13 +- Tutorial copy already mentions Cursor transcripts under `~/.cursor/projects/` + +## How to run (no Docker) + +```bash +# Bun is required (installed to ~/.bun/bin if missing) +export PATH="$HOME/.bun/bin:$PATH" +bun install +bun start # server :4444 + client :4445 +``` + +## Manual checks worth doing + +1. Open `http://localhost:4445` while this Cursor chat is active — you should see an **Agent-Quest** hero (thinking/reading/editing as tools fire). +2. Start a Claude Code or Codex session in parallel — source badges `CLAUDE` / `CODEX` / `CURSOR` should appear. +3. Stop all Cursor chats for >3h (or temporarily rename `~/.cursor`) — empty village + “no install” banner. +4. Map editor still loads (`🗺️`); unrelated to the provider. +5. Session report for a Cursor hero should **omit** the token section (Cursor transcripts have no usage). + +## Out of scope (intentionally) + +- Cursor hooks (`~/.cursor/hooks.json`) +- Windows/Linux verification +- Parsing legacy `chats/*/store.db` diff --git a/docs/specs/2026-08-24-cursor-provider-design.md b/docs/specs/2026-08-24-cursor-provider-design.md new file mode 100644 index 0000000..6373f8a --- /dev/null +++ b/docs/specs/2026-08-24-cursor-provider-design.md @@ -0,0 +1,455 @@ +# Cursor Provider — Design Spec (implementation plan) + +**Date:** 2026-08-24 +**Status:** approved — ready for implementation +**Scope:** add Cursor (IDE agents) as a third provider beside `ClaudeProvider` and `CodexProvider` + +This document is written to be executable step-by-step by an AI implementer +with limited reasoning. Follow the steps in order. Do not deviate from the +contracts. Do not refactor existing code beyond what is listed. + +--- + +## 1. Background + +Agent Quest reads agent session logs from disk and turns them into heroes. +Claude Code writes `~/.claude*/projects/**/*.jsonl`; Codex writes +`~/.codex/sessions/**/rollout-*.jsonl`. This spec adds a third source: + +``` +~/.cursor/projects//agent-transcripts//.jsonl +``` + +Cursor (the IDE) writes one JSONL transcript per agent chat, appended live +while the agent works. The format was verified empirically on a real +installation on 2026-08-24 (120 transcripts, ~25k tool calls scanned). + +## 2. Data format (as observed on disk) + +### 2.1 Directory layout + +- `~/.cursor/projects/` — one subdirectory per opened project. +- Project dir name = the project's absolute path with `/` replaced by `-` + (e.g. `/Volumes/ExtremeSSD/projects/Agent-Quest` → + `Volumes-ExtremeSSD-projects-Agent-Quest`). **Ambiguous**: `-` is also a + valid path character. Decode per §4. +- Inside: `agent-transcripts//.jsonl` — one folder + one JSONL + file per agent chat. Sibling folders (`canvases/`, `mcps/`) must be ignored. + +### 2.2 JSONL line shapes (exhaustive) + +Top-level key sets observed (only these three exist): + +1. **Message line** — keys `{role, message}`: + +```json +{"role":"user","message":{"content":[{"type":"text","text":"..."}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"..."},{"type":"tool_use","name":"Read","input":{"path":"/x/y.ts"}}]}} +``` + + - `role`: `"user"` or `"assistant"`. + - `message.content`: array of blocks. Block types observed: `text`, + `tool_use` **only** (no `tool_result` — Cursor transcripts carry no + tool results and no error markers, so `hasError` is never derivable). + - `tool_use` blocks have `name` and `input` but **no `id`** field. + - No per-line timestamp exists anywhere in the file. + +2. **Turn-end line** — keys `{type, status}` (and `error` when failed): + +```json +{"type":"turn_ended","status":"success"} +{"type":"turn_ended","status":"error","error":"User aborted request"} +``` + +3. Anything else → unknown, drop (return `null`). + +### 2.3 Consequences + +- **Timestamps**: none in-file. The provider must stamp every `ParsedEvent` + with the file's `mtime` captured at read time (see §5.3). +- **Session id**: the `` (file basename without `.jsonl`). It is unique + per chat. Pass it as `sessionId` to the parser; the state manager uses it + verbatim. +- **cwd**: decoded from the project dir name (§4). There is no in-file cwd. +- **No tokens, no model, no subagents** in Cursor transcripts. Token usage + stays zero; the SessionReport UI already omits the token section for + non-Claude sources (§6.5). + +## 3. Type changes (shared between server and client) + +**IMPORTANT**: `server/src/types.ts` and `client/src/types/agent.ts` mirror +each other. Both MUST get the identical change. + +### 3.1 `server/src/types.ts` + +Current: + +```ts +export const AGENT_SOURCES = ['claude', 'codex'] as const; +export type AgentSource = (typeof AGENT_SOURCES)[number]; +``` + +Change to: + +```ts +export const AGENT_SOURCES = ['claude', 'codex', 'cursor'] as const; +export type AgentSource = (typeof AGENT_SOURCES)[number]; +``` + +Also update the comment on `AgentState.source` (line ~62) to: + +```ts + source: AgentSource; // 'claude' | 'codex' | 'cursor' — which client/CLI produced this session +``` + +And the comment on `SessionMeta` / pidfile section stays as-is (it already +says "Claude Code only"). + +### 3.2 `client/src/types/agent.ts` + +Same `AGENT_SOURCES` change (line ~15). Then extend the badge color map +(line ~24): + +```ts +export const SOURCE_BADGE_COLOR: Record = { + claude: '#FF9F4A', // orange + codex: '#7ED9CF', // teal + cursor: '#7CB8F7', // sky blue +}; +``` + +## 4. New file: `server/src/parsers/cursor-parser.ts` + +Pure functions only (no filesystem access) so they are unit-testable, +mirroring `codex-parser.ts` structure. + +### 4.1 Tool → activity mapping + +```ts +const READING_TOOLS = new Set(['Read', 'ReadFile', 'Grep', 'Glob', 'rg']); +const EDITING_TOOLS = new Set(['Write', 'StrReplace', 'Edit', 'ApplyPatch', 'Delete']); +const REVIEWING_TOOLS = new Set(['Task', 'Subagent']); +const GIT_COMMAND_PATTERN = /\bgit\s+(commit|push|merge|rebase|cherry-pick)\b/; +``` + +Resolution order for a `tool_use` named `N` with input `I`: + +1. `N` in `READING_TOOLS` → `reading` +2. `N` in `EDITING_TOOLS` → `editing` +3. `N` in `REVIEWING_TOOLS` → `reviewing` +4. `N === 'Shell'` (or `'Bash'`): command string = `I.command` (if string). + If `GIT_COMMAND_PATTERN` matches → `git`, else `bash` +5. everything else (MCP tools, `TodoWrite`, `WebFetch`, `WebSearch`, + `AskQuestion`, `CallMcpTool`, unknown…) → `thinking` + +### 4.2 Exported API + +```ts +export interface CursorLineMeta { + sessionId: string; + cwd: string; + timestamp: number; // provider stamps this (file mtime at read time) +} + +/** Parse one JSONL line. Returns null for noise/unknown/malformed lines. */ +export function parseCursorLine(raw: string, meta: CursorLineMeta): ParsedEvent | null; + +/** Parse a whole file (used on first sight / session start). */ +export function parseCursorFile(contents: string, meta: CursorLineMeta): ParsedEvent[]; + +/** + * Decode a project dir name into an absolute cwd path. + * Greedy left-to-right: starting from '/', try the longest dash-joined + * segment prefix whose path exists on disk (via existsSync), descend into + * it and repeat with the remaining parts. If at some step no existing + * directory matches, join ALL remaining parts with '-' into the current + * path (unverifiable tail) and stop. + * Always returns a string starting with '/'. + */ +export function decodeProjectDir(name: string): string; +``` + +`decodeProjectDir` MUST be injectable for tests — implement it as: + +```ts +export function decodeProjectDir(name: string, exists?: (p: string) => boolean): string +``` + +where `exists` defaults to `node:fs.existsSync`. Tests pass a fake. + +### 4.3 `parseCursorLine` behavior (exact contract) + +Input line → output (or `null`): + +| Line | Output | +|---|---| +| malformed JSON | `null` | +| `role === 'user'` | One `ParsedEvent` with `kind: 'task'`, `activity: 'thinking'`, `currentTask` = concatenation of all `text` block texts (joined with `'\n'`; strip nothing). If the user message contains ``, extract the inner text as `currentTask` instead (regex `/\s*([\s\S]*?)\s*<\/user_query>/`). Empty result → `null`. `toolCalls: []`, `cwd: meta.cwd`, `timestamp: meta.timestamp`, `slug: undefined`. | +| `role === 'assistant'` | Emit ONE event per `tool_use` block? **No.** Emit a SINGLE event containing all `tool_use` blocks as `toolCalls` (the state manager handles batches). `activity` = activity of the FIRST `tool_use` block (by mapping §4.1); if the line has no `tool_use` blocks (text-only) → event with `activity: 'thinking'`, `toolCalls: []`, `isTurnEnd: false`, `lastMessage` = concatenation of `text` blocks (may be `undefined` when no text). For each tool call build `ToolCall { id: \`cursor-${meta.timestamp}-${index}\`, name, timestamp: meta.timestamp, input }`. `file`/`command` detail: from the first tool call — for file tools use `input.path ?? input.file_path` (string only); for Shell use `input.command`; else `undefined`. `kind: 'tool'`, `cwd: meta.cwd`. | +| `type === 'turn_ended'` | Event with `kind: 'tool'`, `activity: 'idle'`, `toolCalls: []`, `isTurnEnd: true`, `hasError: (status !== 'success')`, `cwd: meta.cwd`, `timestamp: meta.timestamp`. | +| anything else | `null` | + +Do NOT set `usage`, `usageMessageId`, or `model` on any Cursor event. + +### 4.4 `parseCursorFile` + +Split on `'\n'`, skip blank lines, run `parseCursorLine` per line, collect +non-null results. Same shape as `parseCodexFile`. + +## 5. New file: `server/src/providers/cursor-provider.ts` + +Mirror `codex-provider.ts` exactly in structure (class `CursorProvider +implements SessionProvider`, `readonly source: AgentSource = 'cursor'`). + +### 5.1 Options + +```ts +export interface CursorProviderOptions { + /** Defaults to `~/.cursor`. */ + cursorRoot?: string; + /** How often to rescan. Default 3000ms. */ + scanIntervalMs?: number; + /** Ignore transcript files whose mtime is older than this when first seen. Default 3h. */ + maxAgeMs?: number; +} +``` + +### 5.2 `start(handlers)` + +1. `stat(cursorRoot)`. Missing → `console.log('[CursorProvider] ${cursorRoot} not found — provider inactive')`, + set nothing, return (same pattern as CodexProvider). Otherwise + `rootExists = true`. +2. `await this.scan()` then `setInterval(() => this.scan().catch(...), scanIntervalMs)`. +3. `console.log('[CursorProvider] watching ${cursorRoot} every ${scanIntervalMs}ms')`. + +`stop()`, `getConfigDirs()` (return `[cursorRoot]` only when `rootExists`) +copy CodexProvider, including the re-entrancy guard (`private scanning`). + +### 5.3 `scan()` + +Re-entrancy guard as in CodexProvider. Then: + +1. List `join(cursorRoot, 'projects')` entries (directories only). If the + dir is missing → return. +2. **Cap**: sort project dirs by mtime desc, keep the first 20. (There can + be hundreds of stale project dirs; only recent ones matter.) +3. For each kept project dir: `decodeProjectDir(dirname)` → `projectCwd`. + Then walk `join(projectDir, 'agent-transcripts')` at depth ≤ 2 collecting + every `*.jsonl` file. +4. For each file: `processFile(filePath, projectCwd)`. + +### 5.4 `processFile(filePath, projectCwd)` + +Track files in `private tracked = new Map()`, +same as CodexProvider. + +1. `stat` the file; skip on error. +2. **First sight**: + - Age (`now - mtimeMs`) > `maxAgeMs` → store sentinel + `{ sessionId: '', cwd: projectCwd, size: s.size }`, return (same + resume-later pattern as Codex). + - Read the whole file. `sessionId` = basename of the file minus `.jsonl`. + Events = `parseCursorFile(contents, { sessionId, cwd: projectCwd, timestamp: Date.now() })`. + - Store `{ sessionId, cwd: projectCwd, size: s.size }`. + - `handlers.onSessionStart({ source: 'cursor', sessionId, configDir: this.cursorRoot, events })`. + - Return. +3. **Follow-up**: if `s.size <= tracked.size` return. If sentinel + (`sessionId === ''`): delete entry, re-call `processFile` (single hop), + return. +4. Slice new bytes `tracked.size → s.size`, cut at the last `'\n'` (partial + line guard — identical to CodexProvider). Parse the complete chunk with + `parseCursorLine` per line, **stamping `timestamp: s.mtimeMs`** (use the + stat result of this scan so events share the observed write time). +5. Advance `tracked.size += Buffer.byteLength(complete, 'utf8')`. +6. If any events → `handlers.onSessionEvents({ source: 'cursor', sessionId, configDir: this.cursorRoot, events })`. + +**Timestamp note**: first-sight events use `Date.now()` (file just read); +incremental events use the current scan's `s.mtimeMs`. Both are fine for +`lastEvent` arithmetic since they are wall-clock at read time. + +## 6. Server wiring: `server/src/index.ts` + +Apply these exact changes: + +1. Import: `import { CursorProvider } from './providers/cursor-provider';` +2. After `const codexProvider = new CodexProvider({ maxAgeMs: SESSION_MAX_AGE_MS });` + add: + + ```ts + const cursorProvider = new CursorProvider({ maxAgeMs: SESSION_MAX_AGE_MS }); + ``` +3. `allConfigDirs()` becomes: + + ```ts + function allConfigDirs(): string[] { + return [...claudeProvider.getConfigDirs(), ...codexProvider.getConfigDirs(), ...cursorProvider.getConfigDirs()]; + } + ``` +4. In the boot sequence, after `await codexProvider.start(providerHandlers);` + add `await cursorProvider.start(providerHandlers);` +5. Update the "no install detected" warning string to: + `'[Server] WARNING: no Claude Code, Codex or Cursor installation detected. Start a session with any of them to see heroes here.'` +6. Update the `SessionRegistry` seed comment block (lines ~213-220) to keep + saying Claude-only; no code change there. + +## 7. Server tests (new files, `bun test`) + +### 7.1 `server/src/parsers/cursor-parser.test.ts` + +Cover at minimum: + +- `parseCursorLine` user message → `kind: 'task'`, `currentTask` extracted + from `` when present. +- `parseCursorLine` assistant with two `tool_use` blocks → one event, + `toolCalls.length === 2`, activity = first tool's mapping. +- `parseCursorLine` assistant text-only → `activity: 'thinking'`, + `lastMessage` set. +- Tool mapping: `Read`→reading, `StrReplace`→editing, `Shell`→bash, + `Shell` with `git commit`→git, `Task`→reviewing, `CallMcpTool`→thinking. +- `turn_ended` success → `isTurnEnd: true`, `hasError: false`. +- `turn_ended` error → `hasError: true`. +- Malformed JSON line → `null`; unknown top-level shape → `null`. +- `decodeProjectDir` with fake `exists`: + - `decodeProjectDir('Volumes-ExtremeSSD-projects-Agent-Quest', fake)` where + fake confirms `/Volumes`, `/Volumes/ExtremeSSD`, + `/Volumes/ExtremeSSD/projects`, and + `/Volumes/ExtremeSSD/projects/Agent-Quest` → returns + `/Volumes/ExtremeSSD/projects/Agent-Quest`. + - Fallback: when `exists` returns false for everything, returns + `'/Volumes-ExtremeSSD-projects-Agent-Quest'` (all parts joined). + +### 7.2 `server/src/providers/cursor-provider.test.ts` + +Mirror `codex-provider.test.ts` style (`mkdtempSync`, manual `scan()` calls, +`scanIntervalMs: 60_000` so no auto-poll): + +- `new CursorProvider().source === 'cursor'`. +- Build tree `/projects/Users-test-MyProj/agent-transcripts/aaa/bbb... ` + — actually: `/projects//agent-transcripts//.jsonl` + with one user-message line. `start()` → `onSessionStart` called once, + sessionId = ``, one task event. +- Append an assistant tool_use line, call `scan()` → `onSessionEvents` with + one event. +- File older than `maxAgeMs` (set `utimesSync` into the past) → no + `onSessionStart`. +- `cursorRoot` missing → `getConfigDirs()` returns `[]` after `start()`. + +## 8. Client changes + +### 8.1 Badge logic — `client/src/App.tsx` (lines ~82-84) + +Current: + +```ts +const showSourceBadge = liveAgents.some((a) => a.source === 'claude') + && liveAgents.some((a) => a.source === 'codex'); +``` + +Replace with (badge shows whenever ≥ 2 distinct sources are live): + +```ts +const liveSources = new Set(liveAgents.map((a) => a.source)); +const showSourceBadge = liveSources.size >= 2; +``` + +### 8.2 Badge logic — `client/src/game/scenes/VillageScene.ts` (lines ~657-660) + +Current: + +```ts +const hasClaude = liveAgents.some((a) => a.source === 'claude'); +const hasCodex = liveAgents.some((a) => a.source === 'codex'); +const showSourceBadge = hasClaude && hasCodex; +``` + +Replace with: + +```ts +const showSourceBadge = new Set(liveAgents.map((a) => a.source)).size >= 2; +``` + +Update the surrounding comment ("Mixed-provider mode…") to say "two or more +providers" instead of "both Claude and Codex". + +### 8.3 No other badge code changes + +`DetailPanel`, `PartyBar`, `ActivityRow`, `HeroSprite` all render +`agent.source.toUpperCase()` with `SOURCE_BADGE_COLOR[agent.source]` — +the type map additions in §3.2 are sufficient. `HeroSprite` comment +(line ~407: "Show or hide the source badge (`CODEX` / `CLAUDE`)") → update +to "(`CLAUDE` / `CODEX` / `CURSOR`)". + +### 8.4 Copy updates (exact strings) + +| File | Old | New | +|---|---|---| +| `client/src/components/NoInstallBanner.tsx` title | `No Claude Code or Codex installation detected` | `No Claude Code, Codex or Cursor installation detected` | +| same file body | `The server found no ~/.claude* or ~/.codex directory with session logs. Start a Claude Code or Codex session to see heroes appear here.` | `The server found no ~/.claude*, ~/.codex or ~/.cursor directory with session logs. Start a Claude Code, Codex or Cursor session to see heroes appear here.` | +| same file JSDoc (line ~13) | `"Neither Claude Code nor Codex is installed"` | `"Neither Claude Code, Codex nor Cursor is installed"` | +| `client/src/components/ActivityFeed.tsx:173` | `Launch Claude Code or Codex in any project — it'll appear here.` | `Launch Claude Code, Codex or Cursor in any project — it'll appear here.` | +| `client/src/hooks/useAgentState.ts:15` | `neither ~/.claude* nor ~/.codex install on disk` | `no ~/.claude*, ~/.codex or ~/.cursor install on disk` | +| `client/src/components/Tutorial.tsx` tagline (line ~36) | `Use Claude Code CLI or Codex as usual — each session auto-spawns a hero on the dashboard, live.` | `Use Claude Code, Codex or Cursor as usual — each session auto-spawns a hero on the dashboard, live.` | +| Tutorial intro (line ~51-54) | `turns your Claude Code and Codex sessions into a 2D fantasy village` | `turns your Claude Code, Codex and Cursor sessions into a 2D fantasy village` | +| Tutorial compat (line ~64-74) | after the Codex sentence add | append: ` It also works with Cursor — the IDE agent chats are read from ~/.cursor/projects/.` (keep the existing final sentence about the Claude desktop app/claude.ai) | +| Tutorial badge paragraph (line ~90-93) | `When both Claude Code and Codex have active agents, each hero shows a small CLAUDE or CODEX badge so you can tell which CLI it came from.` | `When agents from more than one source are active, each hero shows a small CLAUDE, CODEX or CURSOR badge so you can tell where it came from.` | +| Tutorial how-to (line ~98) | `Heroes appear in real time as you spawn new Claude Code or Codex sessions.` | `Heroes appear in real time as you spawn new Claude Code, Codex or Cursor sessions.` | +| Tutorial privacy (line ~133-135) | `Agent Quest only reads the logs Claude Code and Codex already keep on your disk.` | `Agent Quest only reads the logs Claude Code, Codex and Cursor already keep on your disk.` | +| Tutorial platform — add after the Codex paragraph | — | new paragraph: `Cursor: tested on macOS. Windows/Linux not yet verified.` | +| `client/src/components/SessionReport.tsx` comment (line ~79) | `Tokens — Claude only. Codex doesn't report usage…` | `Tokens — Claude only. Codex and Cursor don't report usage…` | + +### 8.5 `client/src/components/configDirLabel.ts` + +`.cursor` already renders as `cursor` via the generic dot-strip fallback. +Add an explicit branch for clarity and parity with `.claude`/`.codex`: + +```ts +if (base === '.cursor') return 'cursor'; +``` + +Add a test case to `client/src/components/configDirLabel.test.ts`: +`it('returns "cursor" for ~/.cursor', () => expect(configDirLabel('~/.cursor')).toBe('cursor'));` +(adjust to the existing test style — inspect the file first). + +## 9. Documentation + +### 9.1 `README.md` + +- Tagline (line 6): `A fantasy village dashboard for monitoring your Claude Code and Codex agents.` → + `A fantasy village dashboard for monitoring your Claude Code, Codex and Cursor agents.` +- Features bullet (line ~48): `Real-time visualization of active Claude Code and Codex sessions` → + `Real-time visualization of active Claude Code, Codex and Cursor sessions` +- Features bullet (line ~49): auto-discovery bullet — append `and of ~/.cursor if present`. +- "Why?" paragraph (line ~44): mention `~/.cursor` alongside the others. +- Requirements bullet (line ~58): `(one or more ~/.claude* directories, and/or ~/.codex, with session logs)` → + `(one or more ~/.claude* directories, and/or ~/.codex, and/or ~/.cursor, with session logs)`. +- Platform matrix (line ~223): add row `| Cursor | ✓ | not yet verified | not yet verified |` + and a sentence after the table: `Cursor support reads the IDE's local agent-chat transcripts (~/.cursor/projects/); it has been tested on macOS only so far.` +- Hooks bullet (line ~52): append `— Codex and Cursor don't use this path`. +- Troubleshooting empty-village entry (line ~147): update to mention all three. + +### 9.2 `CLAUDE.md` + +- Architecture bullet: add `CursorProvider watches ~/.cursor/projects/**/agent-transcripts/**/*.jsonl for Cursor IDE agent chats.` Keep the note that hooks and SessionRegistry are Claude-only; add: `Cursor liveness is inferred from transcript-file activity, same as Codex.` +- Data flow line: append `and ~/.cursor/projects//agent-transcripts/**/*.jsonl → CursorProvider`. +- Key Type bullet: `source` field now `'claude' | 'codex' | 'cursor'`; `configDir` can also be `~/.cursor`. + +## 10. Explicitly out of scope + +- Cursor hooks integration (`~/.cursor/hooks.json` exists but is command-type + only; wiring it is a separate future feature). +- Parsing `~/.cursor/chats/*/store.db` (SQLite, legacy IDE chat storage). +- Cursor on Windows/Linux verification. +- Any change to map editor, themes, notifications, map storage. + +## 11. Acceptance checklist + +- [ ] `AGENT_SOURCES` includes `'cursor'` in BOTH `server/src/types.ts` and + `client/src/types/agent.ts`; `SOURCE_BADGE_COLOR` has `cursor`. +- [ ] `cursor-parser.ts` + `cursor-provider.ts` exist per §4/§5 contracts. +- [ ] `index.ts` wires `CursorProvider` into `allConfigDirs()` and boot. +- [ ] New tests (§7) pass: `cd server && bun test`. +- [ ] All existing tests still pass. +- [ ] TypeScript strict compiles with zero errors (`noUncheckedIndexedAccess` on). +- [ ] Client badge logic uses "≥ 2 distinct sources" (§8.1/§8.2). +- [ ] All copy updates in §8.4/§9 applied. +- [ ] No `any` introduced anywhere. diff --git a/install.sh b/install.sh index 35b21e7..e4d6bca 100755 --- a/install.sh +++ b/install.sh @@ -88,7 +88,7 @@ semver_ge() { # ------------------------------------------------------------------ banner cat < { // CLI install"). await claudeProvider.start(providerHandlers); await codexProvider.start(providerHandlers); +await cursorProvider.start(providerHandlers); // If neither provider found anything on disk, emit a single aggregated // warning so the user sees one clear diagnostic line instead of per-provider // chatter. The client banner shows the equivalent message to the user. if (allConfigDirs().length === 0) { - console.warn('[Server] WARNING: no Claude Code or Codex install detected. Start a session with either to see heroes here.'); + console.warn('[Server] WARNING: no Claude Code, Codex or Cursor installation detected. Start a session with any of them to see heroes here.'); } // Seed the liveness registry with the same config dirs the watcher just diff --git a/server/src/parsers/cursor-parser.test.ts b/server/src/parsers/cursor-parser.test.ts new file mode 100644 index 0000000..400013c --- /dev/null +++ b/server/src/parsers/cursor-parser.test.ts @@ -0,0 +1,121 @@ +// server/src/parsers/cursor-parser.test.ts +import { test, expect } from 'bun:test'; +import { parseCursorLine, parseCursorFile, decodeProjectDir } from './cursor-parser'; +import type { CursorLineMeta } from './cursor-parser'; + +const META: CursorLineMeta = { sessionId: 'abc-123', cwd: '/proj', timestamp: 1_700_000_000_000 }; + +test('parseCursorLine returns null for malformed JSON', () => { + expect(parseCursorLine('{not json', META)).toBeNull(); +}); + +test('parseCursorLine returns null for unknown top-level shape', () => { + expect(parseCursorLine('{"foo":"bar"}', META)).toBeNull(); +}); + +test('parseCursorLine extracts currentTask from a user message', () => { + const raw = JSON.stringify({ + role: 'user', + message: { content: [{ type: 'text', text: 'hello' }] }, + }); + const ev = parseCursorLine(raw, META); + expect(ev).not.toBeNull(); + expect(ev!.kind).toBe('task'); + expect(ev!.activity).toBe('thinking'); + expect(ev!.currentTask).toBe('hello'); +}); + +test('parseCursorLine extracts body when present', () => { + const raw = JSON.stringify({ + role: 'user', + message: { content: [{ type: 'text', text: '...\n\nDo the thing\n' }] }, + }); + const ev = parseCursorLine(raw, META); + expect(ev!.currentTask).toBe('Do the thing'); +}); + +test('parseCursorLine batches multiple tool_use blocks into one event', () => { + const raw = JSON.stringify({ + role: 'assistant', + message: { + content: [ + { type: 'text', text: 'doing work' }, + { type: 'tool_use', name: 'Read', input: { path: '/a.ts' } }, + { type: 'tool_use', name: 'Grep', input: { pattern: 'x' } }, + ], + }, + }); + const ev = parseCursorLine(raw, META); + expect(ev).not.toBeNull(); + expect(ev!.kind).toBe('tool'); + expect(ev!.toolCalls.length).toBe(2); + expect(ev!.activity).toBe('reading'); // first tool + expect(ev!.file).toBe('/a.ts'); +}); + +test('tool mapping: Read/StrReplace/Shell/git/Task/MCP', () => { + const line = (name: string, input: unknown) => JSON.stringify({ + role: 'assistant', + message: { content: [{ type: 'tool_use', name, input }] }, + }); + expect(parseCursorLine(line('Read', { path: '/f' }), META)!.activity).toBe('reading'); + expect(parseCursorLine(line('StrReplace', { path: '/f' }), META)!.activity).toBe('editing'); + expect(parseCursorLine(line('Shell', { command: 'npm test' }), META)!.activity).toBe('bash'); + expect(parseCursorLine(line('Shell', { command: 'git commit -m x' }), META)!.activity).toBe('git'); + expect(parseCursorLine(line('Task', { description: 'review' }), META)!.activity).toBe('reviewing'); + expect(parseCursorLine(line('CallMcpTool', { server: 'x' }), META)!.activity).toBe('thinking'); +}); + +test('assistant text-only line sets lastMessage', () => { + const raw = JSON.stringify({ + role: 'assistant', + message: { content: [{ type: 'text', text: 'partial reply' }] }, + }); + const ev = parseCursorLine(raw, META); + expect(ev!.activity).toBe('thinking'); + expect(ev!.toolCalls.length).toBe(0); + expect(ev!.lastMessage).toBe('partial reply'); +}); + +test('turn_ended success → isTurnEnd true, hasError false', () => { + const ev = parseCursorLine('{"type":"turn_ended","status":"success"}', META); + expect(ev).not.toBeNull(); + expect(ev!.isTurnEnd).toBe(true); + expect(ev!.hasError).toBe(false); + expect(ev!.activity).toBe('idle'); +}); + +test('turn_ended error → hasError true', () => { + const ev = parseCursorLine('{"type":"turn_ended","status":"error","error":"User aborted request"}', META); + expect(ev!.isTurnEnd).toBe(true); + expect(ev!.hasError).toBe(true); +}); + +test('parseCursorFile skips blank lines and noise', () => { + const raw = [ + '', + JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: 'hi' }] } }), + '{"foo":"bar"}', + JSON.stringify({ type: 'turn_ended', status: 'success' }), + ].join('\n'); + const events = parseCursorFile(raw, META); + expect(events.length).toBe(2); +}); + +test('decodeProjectDir greedily resolves existing path segments', () => { + const existing = new Set([ + '/Volumes', + '/Volumes/ExtremeSSD', + '/Volumes/ExtremeSSD/projects', + '/Volumes/ExtremeSSD/projects/Agent-Quest', + ]); + const fake = (p: string) => existing.has(p); + expect(decodeProjectDir('Volumes-ExtremeSSD-projects-Agent-Quest', fake)) + .toBe('/Volumes/ExtremeSSD/projects/Agent-Quest'); +}); + +test('decodeProjectDir falls back to joined tail when nothing exists', () => { + const fake = () => false; + expect(decodeProjectDir('Volumes-ExtremeSSD-projects-Agent-Quest', fake)) + .toBe('/Volumes-ExtremeSSD-projects-Agent-Quest'); +}); \ No newline at end of file diff --git a/server/src/parsers/cursor-parser.ts b/server/src/parsers/cursor-parser.ts new file mode 100644 index 0000000..cc49d1d --- /dev/null +++ b/server/src/parsers/cursor-parser.ts @@ -0,0 +1,243 @@ +// server/src/parsers/cursor-parser.ts +// +// Parses Cursor (IDE) agent-chat transcripts, discovered under +// `~/.cursor/projects//agent-transcripts//.jsonl`. +// +// Format (verified empirically on a real install, 2026-08-24): +// - message lines: {"role":"user"|"assistant","message":{"content":[blocks]}} +// blocks are {type:"text"} or {type:"tool_use", name, input} (no `id`, no +// tool_result blocks, no per-line timestamp). +// - turn-end lines: {"type":"turn_ended","status":"success"|"error"[,"error":...]} +// +// Cursor transcripts carry no timestamps, no token usage, no model id and no +// subagent markers, so events are stamped by the provider (file mtime / Date.now() +// at read time) and the token/model/subagent fields are left undefined. +import { existsSync } from 'node:fs'; +import type { AgentActivity, ToolCall } from '../types'; +import type { ParsedEvent } from './session-parser'; + +const READING_TOOLS = new Set(['Read', 'ReadFile', 'Grep', 'Glob', 'rg']); +const EDITING_TOOLS = new Set(['Write', 'StrReplace', 'Edit', 'ApplyPatch', 'Delete']); +const REVIEWING_TOOLS = new Set(['Task', 'Subagent']); +const GIT_COMMAND_PATTERN = /\bgit\s+(commit|push|merge|rebase|cherry-pick)\b/; + +export interface CursorLineMeta { + sessionId: string; + cwd: string; + /** Wall-clock stamped by the provider — Cursor lines carry no timestamp. */ + timestamp: number; +} + +interface CursorRawLine { + role?: unknown; + type?: unknown; + status?: unknown; + message?: { content?: unknown }; +} + +interface CursorTextBlock { type: 'text'; text?: unknown } +interface CursorToolUseBlock { + type: 'tool_use'; + name?: unknown; + input?: unknown; +} +type CursorContentBlock = CursorTextBlock | CursorToolUseBlock; + +function toolNameToActivity(name: string, input: Record): AgentActivity { + if (READING_TOOLS.has(name)) return 'reading'; + if (EDITING_TOOLS.has(name)) return 'editing'; + if (REVIEWING_TOOLS.has(name)) return 'reviewing'; + if (name === 'Shell' || name === 'Bash') { + const cmd = input['command']; + if (typeof cmd === 'string' && GIT_COMMAND_PATTERN.test(cmd)) return 'git'; + return 'bash'; + } + return 'thinking'; +} + +/** Extract a human-readable detail string from a tool_use block, if any. */ +function toolDetail(name: string, input: Record): string | undefined { + if (name === 'Shell' || name === 'Bash') { + const cmd = input['command']; + return typeof cmd === 'string' ? cmd : undefined; + } + const filePath = input['path'] ?? input['file_path']; + return typeof filePath === 'string' ? filePath : undefined; +} + +function asRecord(v: unknown): Record { + return v !== null && typeof v === 'object' && !Array.isArray(v) + ? (v as Record) + : {}; +} + +function extractTexts(blocks: CursorContentBlock[]): string { + const parts: string[] = []; + for (const b of blocks) { + if (b.type === 'text' && typeof b.text === 'string' && b.text.length > 0) { + parts.push(b.text); + } + } + return parts.join('\n'); +} + +/** Pull the innermost `` body out of a user message, if present. */ +function extractUserQuery(text: string): string | undefined { + const m = text.match(/\s*([\s\S]*?)\s*<\/user_query>/); + return m?.[1]; +} + +/** Decode a Cursor project dir name (`path` with `/` → `-`) back into an absolute cwd. */ +export function decodeProjectDir( + name: string, + exists: (p: string) => boolean = existsSync, +): string { + const parts = name.split('-'); + let current = '/'; + let i = 0; + while (i < parts.length) { + let matched = false; + // Greedy: try the longest dash-joined prefix that exists on disk. + for (let j = parts.length; j > i; j--) { + const candidate = current + parts.slice(i, j).join('-'); + if (exists(candidate) && j > i) { + current = candidate + '/'; + i = j; + matched = true; + break; + } + } + if (!matched) { + // No further existing segment — treat the remaining parts as one + // opaque (unverifiable) tail and stop. + current += parts.slice(i).join('-'); + break; + } + } + return current.replace(/\/$/, ''); +} + +export function parseCursorLine(raw: string, meta: CursorLineMeta): ParsedEvent | null { + let line: CursorRawLine; + try { + line = JSON.parse(raw) as CursorRawLine; + } catch { + return null; + } + + // Turn-end marker. + if (line.type === 'turn_ended') { + return { + sessionId: meta.sessionId, + slug: undefined, + timestamp: meta.timestamp, + activity: 'idle', + toolCalls: [], + file: undefined, + command: undefined, + cwd: meta.cwd, + kind: 'tool', + isTurnEnd: true, + hasError: line.status !== 'success', + }; + } + + // Message line. Requirements: `role` and `message.content` present. + if (typeof line.role !== 'string') return null; + const msg = line.message; + if (msg === undefined || msg === null || typeof msg !== 'object') return null; + if (!Array.isArray(msg.content)) return null; + + const blocks = msg.content as CursorContentBlock[]; + const role = line.role; + + if (role === 'user') { + const text = extractTexts(blocks); + if (text.length === 0) return null; + const currentTask = extractUserQuery(text) ?? text; + return { + sessionId: meta.sessionId, + slug: undefined, + timestamp: meta.timestamp, + activity: 'thinking', + toolCalls: [], + file: undefined, + command: undefined, + cwd: meta.cwd, + kind: 'task', + currentTask, + }; + } + + if (role === 'assistant') { + const toolBlocks = blocks.filter((b): b is CursorToolUseBlock => b.type === 'tool_use'); + const text = extractTexts(blocks); + + if (toolBlocks.length === 0) { + // Text-only assistant line: a partial reply. Track lastMessage only. + const lastMessage = text.length > 0 ? text : undefined; + if (lastMessage === undefined) return null; + return { + sessionId: meta.sessionId, + slug: undefined, + timestamp: meta.timestamp, + activity: 'thinking', + toolCalls: [], + file: undefined, + command: undefined, + cwd: meta.cwd, + kind: 'tool', + lastMessage, + }; + } + + const first = toolBlocks[0]!; + const firstInput = asRecord(first.input); + const firstName = typeof first.name === 'string' ? first.name : 'unknown'; + const activity = toolNameToActivity(firstName, firstInput); + + const toolCalls: ToolCall[] = toolBlocks.map((tb, index) => { + const name = typeof tb.name === 'string' ? tb.name : 'unknown'; + const input = asRecord(tb.input); + return { + id: `cursor-${meta.timestamp}-${index}`, + name, + timestamp: meta.timestamp, + input, + }; + }); + + const detail = toolDetail(firstName, firstInput); + const event: ParsedEvent = { + sessionId: meta.sessionId, + slug: undefined, + timestamp: meta.timestamp, + activity, + toolCalls, + file: undefined, + command: undefined, + cwd: meta.cwd, + kind: 'tool', + }; + if (detail !== undefined) { + if (firstName === 'Shell' || firstName === 'Bash') { + event.command = detail; + } else { + event.file = detail; + } + } + return event; + } + + return null; +} + +export function parseCursorFile(contents: string, meta: CursorLineMeta): ParsedEvent[] { + const out: ParsedEvent[] = []; + for (const line of contents.split('\n')) { + if (line.trim() === '') continue; + const ev = parseCursorLine(line, meta); + if (ev !== null) out.push(ev); + } + return out; +} \ No newline at end of file diff --git a/server/src/providers/cursor-provider.test.ts b/server/src/providers/cursor-provider.test.ts new file mode 100644 index 0000000..3f023f2 --- /dev/null +++ b/server/src/providers/cursor-provider.test.ts @@ -0,0 +1,183 @@ +// server/src/providers/cursor-provider.test.ts +import { test, expect } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { CursorProvider } from './cursor-provider'; + +function makeUserMessage(text: string): string { + return JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text }] } }); +} + +function makeToolUse(): string { + return JSON.stringify({ + role: 'assistant', + message: { content: [{ type: 'tool_use', name: 'Read', input: { path: '/proj/a.ts' } }] }, + }); +} + +function makeTurnEnded(status = 'success'): string { + return JSON.stringify({ type: 'turn_ended', status }); +} + +test('CursorProvider identifies as cursor', () => { + const p = new CursorProvider(); + expect(p.source).toBe('cursor'); +}); + +test('CursorProvider discovers transcripts, emits session start + events', async () => { + const root = mkdtempSync(join(tmpdir(), 'cursor-test-')); + const projDir = join(root, 'projects', 'Users-test-MyProj'); + const transcriptsDir = join(projDir, 'agent-transcripts', 'aaa-111'); + mkdirSync(transcriptsDir, { recursive: true }); + const file = join(transcriptsDir, 'aaa-111.jsonl'); + writeFileSync(file, makeUserMessage('hello') + '\n'); + + const starts: unknown[] = []; + const updates: unknown[] = []; + const p = new CursorProvider({ cursorRoot: root, scanIntervalMs: 60_000 }); + + await p.start({ + onSessionStart: (payload) => { starts.push(payload); }, + onSessionEvents: (payload) => { updates.push(payload); }, + }); + + expect(starts.length).toBe(1); + const first = starts[0] as { sessionId: string; events: unknown[] }; + expect(first.sessionId).toBe('aaa-111'); + expect(first.events.length).toBe(1); // one task event + + appendFileSync(file, makeToolUse() + '\n'); + await (p as unknown as { scan: () => Promise }).scan(); + expect(updates.length).toBe(1); + expect((updates[0] as { events: unknown[] }).events.length).toBe(1); + + p.stop(); +}); + +test('CursorProvider stamps first-ingest events with file mtime, not now', async () => { + const root = mkdtempSync(join(tmpdir(), 'cursor-test-')); + const transcriptsDir = join(root, 'projects', 'Users-test-MyProj', 'agent-transcripts', 'mtime-333'); + mkdirSync(transcriptsDir, { recursive: true }); + const file = join(transcriptsDir, 'mtime-333.jsonl'); + writeFileSync(file, makeUserMessage('hello') + '\n'); + + const { utimesSync } = await import('node:fs'); + const stamped = Date.now() - 10 * 60_000; + utimesSync(file, new Date(stamped), new Date(stamped)); + + const starts: Array<{ events: Array<{ timestamp: number }> }> = []; + const p = new CursorProvider({ cursorRoot: root, scanIntervalMs: 60_000 }); + await p.start({ + onSessionStart: (payload) => { starts.push(payload as { events: Array<{ timestamp: number }> }); }, + onSessionEvents: () => {}, + }); + + expect(starts.length).toBe(1); + const ts = starts[0]!.events[0]!.timestamp; + expect(Math.abs(ts - stamped)).toBeLessThan(2000); + expect(Date.now() - ts).toBeGreaterThan(8 * 60_000); + + p.stop(); +}); + +test('CursorProvider skips turn-ended chats older than endedQuietMs', async () => { + const root = mkdtempSync(join(tmpdir(), 'cursor-test-')); + const transcriptsDir = join(root, 'projects', 'Users-test-MyProj', 'agent-transcripts', 'ended-444'); + mkdirSync(transcriptsDir, { recursive: true }); + const file = join(transcriptsDir, 'ended-444.jsonl'); + writeFileSync(file, makeUserMessage('done') + '\n' + makeTurnEnded() + '\n'); + + const { utimesSync } = await import('node:fs'); + const old = new Date(Date.now() - 20 * 60_000); + utimesSync(file, old, old); + + const starts: unknown[] = []; + const p = new CursorProvider({ + cursorRoot: root, + scanIntervalMs: 60_000, + endedQuietMs: 15 * 60_000, + }); + await p.start({ onSessionStart: (payload) => { starts.push(payload); }, onSessionEvents: () => {} }); + + expect(starts.length).toBe(0); + p.stop(); +}); + +test('CursorProvider still spawns a recently ended chat', async () => { + const root = mkdtempSync(join(tmpdir(), 'cursor-test-')); + const transcriptsDir = join(root, 'projects', 'Users-test-MyProj', 'agent-transcripts', 'ended-555'); + mkdirSync(transcriptsDir, { recursive: true }); + const file = join(transcriptsDir, 'ended-555.jsonl'); + writeFileSync(file, makeUserMessage('just done') + '\n' + makeTurnEnded() + '\n'); + + const starts: unknown[] = []; + const p = new CursorProvider({ + cursorRoot: root, + scanIntervalMs: 60_000, + endedQuietMs: 15 * 60_000, + }); + await p.start({ onSessionStart: (payload) => { starts.push(payload); }, onSessionEvents: () => {} }); + + expect(starts.length).toBe(1); + p.stop(); +}); + +test('CursorProvider still spawns a cold chat that has not ended', async () => { + const root = mkdtempSync(join(tmpdir(), 'cursor-test-')); + const transcriptsDir = join(root, 'projects', 'Users-test-MyProj', 'agent-transcripts', 'open-666'); + mkdirSync(transcriptsDir, { recursive: true }); + const file = join(transcriptsDir, 'open-666.jsonl'); + writeFileSync(file, makeUserMessage('still going') + '\n'); + + const { utimesSync } = await import('node:fs'); + const old = new Date(Date.now() - 20 * 60_000); + utimesSync(file, old, old); + + const starts: unknown[] = []; + const p = new CursorProvider({ + cursorRoot: root, + scanIntervalMs: 60_000, + endedQuietMs: 15 * 60_000, + }); + await p.start({ onSessionStart: (payload) => { starts.push(payload); }, onSessionEvents: () => {} }); + + expect(starts.length).toBe(1); + p.stop(); +}); + +test('CursorProvider skips files older than maxAgeMs', async () => { + const root = mkdtempSync(join(tmpdir(), 'cursor-test-')); + const transcriptsDir = join(root, 'projects', 'Users-test-MyProj', 'agent-transcripts', 'old-222'); + mkdirSync(transcriptsDir, { recursive: true }); + const file = join(transcriptsDir, 'old-222.jsonl'); + writeFileSync(file, makeUserMessage('old') + '\n'); + + const { utimesSync } = await import('node:fs'); + const old = new Date(Date.now() - 5 * 3600_000); + utimesSync(file, old, old); + + const starts: unknown[] = []; + const p = new CursorProvider({ cursorRoot: root, scanIntervalMs: 60_000, maxAgeMs: 3 * 3600_000 }); + + await p.start({ onSessionStart: (payload) => { starts.push(payload); }, onSessionEvents: () => {} }); + + expect(starts.length).toBe(0); + p.stop(); +}); + +test('CursorProvider.getConfigDirs returns [] when root missing', async () => { + const p = new CursorProvider({ cursorRoot: '/tmp/definitely-not-real-cursor-xyz-9999', scanIntervalMs: 60_000 }); + await p.start({ onSessionStart: () => {}, onSessionEvents: () => {} }); + expect(p.getConfigDirs()).toEqual([]); + p.stop(); +}); + +test('CursorProvider reports cursorRoot once start() confirms it exists', async () => { + const root = mkdtempSync(join(tmpdir(), 'cursor-test-')); + mkdirSync(join(root, 'projects')); + const p = new CursorProvider({ cursorRoot: root, scanIntervalMs: 60_000 }); + await p.start({ onSessionStart: () => {}, onSessionEvents: () => {} }); + expect(p.getConfigDirs()).toEqual([root]); + p.stop(); +}); \ No newline at end of file diff --git a/server/src/providers/cursor-provider.ts b/server/src/providers/cursor-provider.ts new file mode 100644 index 0000000..5537fed --- /dev/null +++ b/server/src/providers/cursor-provider.ts @@ -0,0 +1,216 @@ +// server/src/providers/cursor-provider.ts +import { readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import { parseCursorLine, parseCursorFile, decodeProjectDir } from '../parsers/cursor-parser'; +import type { ParsedEvent } from '../parsers/session-parser'; +import type { AgentSource } from '../types'; +import type { ProviderHandlers, SessionProvider } from './types'; + +export interface CursorProviderOptions { + /** Defaults to `~/.cursor`. */ + cursorRoot?: string; + /** How often to rescan the projects tree. Default 3s. */ + scanIntervalMs?: number; + /** Ignore transcript files whose mtime is older than this when first seen. Default 3h. */ + maxAgeMs?: number; + /** + * Skip spawning a hero for chats whose last event is a turn-end and whose + * file mtime is older than this. Default 15 min. Recently finished chats + * still appear as waiting; cold finished ones stay silent until they resume. + */ + endedQuietMs?: number; +} + +interface TrackedFile { + sessionId: string; + sessionCwd: string; + size: number; +} + +/** Cursor can accumulate hundreds of project dirs; only the most recently + * modified ones are relevant to live monitoring. */ +const MAX_PROJECT_DIRS = 20; + +export class CursorProvider implements SessionProvider { + readonly source: AgentSource = 'cursor'; + + private readonly cursorRoot: string; + private readonly scanIntervalMs: number; + private readonly maxAgeMs: number; + private readonly endedQuietMs: number; + private handlers: ProviderHandlers | null = null; + private pollInterval: ReturnType | null = null; + private tracked = new Map(); + /** Set when `start()` confirmed the cursor root directory exists. */ + private rootExists = false; + /** Re-entrancy guard for `scan()` — mirrors CodexProvider (see its rationale). */ + private scanning = false; + + constructor(opts: CursorProviderOptions = {}) { + this.cursorRoot = opts.cursorRoot ?? join(homedir(), '.cursor'); + this.scanIntervalMs = opts.scanIntervalMs ?? 3000; + this.maxAgeMs = opts.maxAgeMs ?? 3 * 60 * 60_000; + this.endedQuietMs = opts.endedQuietMs ?? 15 * 60_000; + } + + async start(handlers: ProviderHandlers): Promise { + this.handlers = handlers; + + const rootStat = await stat(this.cursorRoot).catch(() => null); + if (rootStat === null || !rootStat.isDirectory()) { + console.log(`[CursorProvider] ${this.cursorRoot} not found — provider inactive`); + return; + } + this.rootExists = true; + + await this.scan(); + this.pollInterval = setInterval(() => { + this.scan().catch((err) => { + console.error('[CursorProvider] scan error:', err); + }); + }, this.scanIntervalMs); + + console.log(`[CursorProvider] watching ${this.cursorRoot} every ${this.scanIntervalMs}ms`); + } + + stop(): void { + if (this.pollInterval !== null) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + this.handlers = null; + } + + getConfigDirs(): readonly string[] { + return this.rootExists ? [this.cursorRoot] : []; + } + + private async scan(): Promise { + if (this.scanning) return; + this.scanning = true; + try { + const projectsDir = join(this.cursorRoot, 'projects'); + const entries = await readdir(projectsDir, { withFileTypes: true }).catch(() => [] as import('node:fs').Dirent[]); + + // Sort by mtime desc so we only walk the most recently active projects. + const dirs: { name: string; mtimeMs: number }[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const full = join(projectsDir, entry.name); + const s = await stat(full).catch(() => null); + if (s !== null) dirs.push({ name: entry.name, mtimeMs: s.mtimeMs }); + } + dirs.sort((a, b) => b.mtimeMs - a.mtimeMs); + const recent = dirs.slice(0, MAX_PROJECT_DIRS); + + for (const { name } of recent) { + const projectCwd = decodeProjectDir(name); + const transcriptsDir = join(projectsDir, name, 'agent-transcripts'); + const files = await listTranscriptFiles(transcriptsDir); + for (const filePath of files) { + await this.processFile(filePath, projectCwd); + } + } + } finally { + this.scanning = false; + } + } + + private async processFile(filePath: string, projectCwd: string): Promise { + const handlers = this.handlers; + if (handlers === null) return; + + const s = await stat(filePath).catch(() => null); + if (s === null) return; + + const tracked = this.tracked.get(filePath); + if (tracked === undefined) { + const age = Date.now() - s.mtimeMs; + if (age > this.maxAgeMs) { + // Remember size so we still react if it resumes, but don't emit a start. + this.tracked.set(filePath, { sessionId: '', sessionCwd: projectCwd, size: s.size }); + return; + } + + const contents = await Bun.file(filePath).text(); + // The session id is the transcript's own uuid — the file basename. + const sessionId = filePath.split('/').pop()?.replace(/\.jsonl$/, '') ?? 'cursor-session'; + // Stamp with file mtime, not wall-clock: a finished chat whose file is + // still inside maxAge would otherwise look "just ended" and stick as waiting. + const events = parseCursorFile(contents, { sessionId, cwd: projectCwd, timestamp: s.mtimeMs }); + + const last = events[events.length - 1]; + if (last?.isTurnEnd === true && age > this.endedQuietMs) { + // Cold finished chat — remember size so a resume still wakes us, but + // don't spawn a waiting hero for a turn that ended long ago. + this.tracked.set(filePath, { sessionId: '', sessionCwd: projectCwd, size: s.size }); + return; + } + + this.tracked.set(filePath, { sessionId, sessionCwd: projectCwd, size: s.size }); + await handlers.onSessionStart({ + source: this.source, + sessionId, + configDir: this.cursorRoot, + events, + }); + return; + } + + if (s.size <= tracked.size) return; + + // Stale-on-first-sight sentinel that just grew: thread resumed after our + // grace window. Discard the sentinel and re-run as a fresh discovery + // (single hop — as in CodexProvider). + if (tracked.sessionId === '') { + this.tracked.delete(filePath); + await this.processFile(filePath, projectCwd); + return; + } + + const fd = Bun.file(filePath); + const newBytes = fd.slice(tracked.size, s.size); + const newContent = await newBytes.text(); + + const lastNlIdx = newContent.lastIndexOf('\n'); + if (lastNlIdx === -1) { + return; // no complete line yet — wait for next scan + } + const complete = newContent.slice(0, lastNlIdx + 1); + + const events: ParsedEvent[] = []; + for (const line of complete.split('\n')) { + if (line.trim() === '') continue; + const ev = parseCursorLine(line, { sessionId: tracked.sessionId, cwd: tracked.sessionCwd, timestamp: s.mtimeMs }); + if (ev !== null) events.push(ev); + } + tracked.size += Buffer.byteLength(complete, 'utf8'); + + if (events.length === 0) return; + + handlers.onSessionEvents({ + source: this.source, + sessionId: tracked.sessionId, + configDir: this.cursorRoot, + events, + }); + } +} + +async function listTranscriptFiles(root: string, depth = 0): Promise { + if (depth > 2) return []; + const entries = await readdir(root, { withFileTypes: true }).catch(() => [] as import('node:fs').Dirent[]); + const out: string[] = []; + for (const e of entries) { + const p = join(root, e.name); + if (e.isDirectory()) { + const sub = await listTranscriptFiles(p, depth + 1); + out.push(...sub); + } else if (e.isFile() && e.name.endsWith('.jsonl')) { + out.push(p); + } + } + return out; +} \ No newline at end of file diff --git a/server/src/types.ts b/server/src/types.ts index bd9d818..090568a 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -13,7 +13,7 @@ export const HERO_COLORS = [ export type HeroColor = (typeof HERO_COLORS)[number]; // --- Agent source (which external agent produced this session) --- -export const AGENT_SOURCES = ['claude', 'codex'] as const; +export const AGENT_SOURCES = ['claude', 'codex', 'cursor'] as const; export type AgentSource = (typeof AGENT_SOURCES)[number]; // --- Agent activity (maps to village buildings) --- @@ -59,7 +59,7 @@ export interface AgentState { cwd: string; // project working directory configDir: string; // Config dir of the provider that produced the session (e.g. ~/.claude, // ~/.claude-work, ~/.codex) — identifies which installation - source: AgentSource; // 'claude' | 'codex' — which CLI produced this session + source: AgentSource; // 'claude' | 'codex' | 'cursor' — which CLI/client produced this session /** * Model id as emitted by Claude Code in `message.model` of assistant lines * (e.g. `claude-opus-4-6`, `claude-sonnet-4-20250514`). Undefined for Codex