From 636bdadfe6660a9ded6d0d74a0103c90951af3bc Mon Sep 17 00:00:00 2001
From: Edward
Date: Mon, 24 Aug 2026 17:36:40 +0200
Subject: [PATCH 1/2] feat: add Cursor IDE as a third session provider
Watch ~/.cursor/projects/**/agent-transcripts JSONL so Cursor agent chats spawn heroes next to Claude Code and Codex, with source badges when more than one provider is live.
---
CLAUDE.md | 8 +-
README.md | 21 +-
bin/agentquest | 4 +-
client/src/App.tsx | 10 +-
client/src/components/ActivityFeed.tsx | 2 +-
client/src/components/NoInstallBanner.tsx | 8 +-
client/src/components/SessionReport.tsx | 4 +-
client/src/components/Tutorial.tsx | 23 +-
client/src/components/configDirLabel.test.ts | 4 +
client/src/components/configDirLabel.ts | 5 +-
client/src/game/entities/HeroSprite.ts | 2 +-
client/src/game/scenes/VillageScene.ts | 10 +-
client/src/hooks/useAgentState.ts | 2 +-
client/src/types/agent.ts | 3 +-
docs/2026-08-24-cursor-provider-summary.md | 61 +++
.../2026-08-24-cursor-provider-design.md | 455 ++++++++++++++++++
install.sh | 2 +-
server/src/index.ts | 7 +-
server/src/parsers/cursor-parser.test.ts | 121 +++++
server/src/parsers/cursor-parser.ts | 243 ++++++++++
server/src/providers/cursor-provider.test.ts | 88 ++++
server/src/providers/cursor-provider.ts | 197 ++++++++
server/src/types.ts | 4 +-
23 files changed, 1230 insertions(+), 54 deletions(-)
create mode 100644 docs/2026-08-24-cursor-provider-summary.md
create mode 100644 docs/specs/2026-08-24-cursor-provider-design.md
create mode 100644 server/src/parsers/cursor-parser.test.ts
create mode 100644 server/src/parsers/cursor-parser.ts
create mode 100644 server/src/providers/cursor-provider.test.ts
create mode 100644 server/src/providers/cursor-provider.ts
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.
@@ -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.
+ // Only show source badges when two or more providers have a LIVE agent —
+ // completed / error sessions don't count, otherwise the badge would linger
+ // after the last Codex/Cursor 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');
+ 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/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/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.
)}
- {/* 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 ? (
<>
- 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.
- 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..25bb78e 100644
--- a/client/src/game/scenes/VillageScene.ts
+++ b/client/src/game/scenes/VillageScene.ts
@@ -650,14 +650,12 @@ export class VillageScene extends Phaser.Scene {
return true;
});
- // Mixed-provider mode: show source badges only when both Claude and Codex
+ // Mixed-provider mode: show source badges only when two or more providers
// 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.
+ // 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) => 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;
+ 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..e1d56b4
--- /dev/null
+++ b/server/src/providers/cursor-provider.test.ts
@@ -0,0 +1,88 @@
+// 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' } }] },
+ });
+}
+
+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 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..c8f1d09
--- /dev/null
+++ b/server/src/providers/cursor-provider.ts
@@ -0,0 +1,197 @@
+// 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;
+}
+
+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 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;
+ }
+
+ 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';
+ const events = parseCursorFile(contents, { sessionId, cwd: projectCwd, timestamp: Date.now() });
+ 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
From f9f5f0e1526d80dc9033f4e72f0ef686a3bf22a1 Mon Sep 17 00:00:00 2001
From: Edward
Date: Mon, 24 Aug 2026 18:10:37 +0200
Subject: [PATCH 2/2] fix: park stale Cursor sessions instead of leaving
waiting ghosts
Stamp first ingest with transcript mtime and skip cold turn-ended chats
so finished threads don't spawn waiting heroes. Waiting/idle agents older
than 15 minutes fold into Away across Party Bar, village, and Top Bar.
---
client/src/App.tsx | 6 +-
client/src/agentVisibility.test.ts | 35 ++++++++
client/src/agentVisibility.ts | 32 +++++++
client/src/components/BuildingInfoPanel.tsx | 4 +-
client/src/components/Minimap.tsx | 5 +-
client/src/components/PartyBar.tsx | 29 +++---
client/src/components/TopBar.tsx | 7 +-
client/src/game/scenes/VillageScene.ts | 16 ++--
server/src/providers/cursor-provider.test.ts | 95 ++++++++++++++++++++
server/src/providers/cursor-provider.ts | 21 ++++-
10 files changed, 215 insertions(+), 35 deletions(-)
create mode 100644 client/src/agentVisibility.test.ts
create mode 100644 client/src/agentVisibility.ts
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 84938b9..be28c20 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -15,6 +15,7 @@ import { Toasts, type ToastItem } from './components/Toasts';
import type { NotificationEntry } from './components/NotificationMenu';
import { useSettings } from './hooks/useSettings';
import { useAgentNotifications, type ToastPayload } from './hooks/useAgentNotifications';
+import { isLiveRosterAgent } from './agentVisibility';
import './App.css';
export default function App() {
@@ -77,9 +78,8 @@ export default function App() {
: null;
// Only show source badges when two or more providers have a LIVE agent —
- // completed / error sessions don't count, otherwise the badge would linger
- // after the last Codex/Cursor hero finishes just because it's still in state.
- const liveAgents = agents.filter((a) => a.status !== 'completed' && a.status !== 'error');
+ // 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;
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/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/PartyBar.tsx b/client/src/components/PartyBar.tsx
index aff3803..5298c1d 100644
--- a/client/src/components/PartyBar.tsx
+++ b/client/src/components/PartyBar.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { HeroAvatar } from './HeroAvatar';
import { usePartyPrefs } from '../hooks/usePartyPrefs';
+import { isLiveRosterAgent, isParkedAgent } from '../agentVisibility';
import { HERO_LABEL_COLOR, SOURCE_BADGE_COLOR, modelBadge, displayActivity, type AgentState } from '../types/agent';
import './PartyBar.css';
@@ -117,27 +118,25 @@ function PartyRow({ agent, mode, isSelected, onClick, showSourceBadge }: PartyRo
);
}
-const LIVE_STATUSES = new Set(['active', 'waiting', 'idle']);
-
export function PartyBar({ agents, selectedAgentId, onSelectAgent, showSourceBadge }: PartyBarProps) {
const [prefs, updatePrefs] = usePartyPrefs();
const mode: 'full' | 'icons' = prefs.foldState;
const [showCompleted, setShowCompleted] = useState(false);
- // Live roster: active + waiting + idle. 'waiting' must be here — with sticky
- // waiting on the server a finished-turn agent stays 'waiting', and dropping it
- // would make heroes vanish from the party the moment they're done.
- const live = agents.filter((a) => LIVE_STATUSES.has(a.status));
+ // Live roster: active + recent waiting/idle. Sticky waiting stays visible
+ // for a short window so a just-finished turn doesn't vanish, then parks.
+ const live = agents.filter((a) => isLiveRosterAgent(a));
const sorted = [...live].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
const activeCount = live.filter((a) => a.status === 'active').length;
const waitingCount = live.filter((a) => a.status === 'waiting').length;
const idleCount = live.filter((a) => a.status === 'idle').length;
- // Completed sessions, most recently finished first — shown in a collapsible
- // section so finished work stays reviewable instead of just disappearing.
- const completed = agents
- .filter((a) => a.status === 'completed')
+ // Completed + parked waiting/idle, most recently finished first — collapsible
+ // so finished work stays reviewable instead of disappearing from the roster.
+ const away = agents
+ .filter((a) => a.status === 'completed' || isParkedAgent(a))
.sort((a, b) => b.lastEvent - a.lastEvent);
+ const awayLabel = away.some((a) => a.status !== 'completed') ? 'Away' : 'Completed';
const headerParts = [`${activeCount} active`];
if (waitingCount > 0) headerParts.push(`${waitingCount} waiting`);
@@ -181,23 +180,23 @@ export function PartyBar({ agents, selectedAgentId, onSelectAgent, showSourceBad
))}