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.

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. + // 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.
- {completed.length > 0 && ( + {away.length > 0 && (
{showCompleted && (
- {completed.map((agent) => ( + {away.map((agent) => ( 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/game/scenes/VillageScene.ts b/client/src/game/scenes/VillageScene.ts index 25bb78e..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,18 +641,14 @@ 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 two or more providers - // have a LIVE hero. Completed/error sessions don't count, so the badge + // 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) => a.status !== 'completed' && a.status !== 'error'); + const liveAgents = agents.filter((a) => isLiveRosterAgent(a, now)); const showSourceBadge = new Set(liveAgents.map((a) => a.source)).size >= 2; // Remove heroes no longer visible diff --git a/server/src/providers/cursor-provider.test.ts b/server/src/providers/cursor-provider.test.ts index e1d56b4..3f023f2 100644 --- a/server/src/providers/cursor-provider.test.ts +++ b/server/src/providers/cursor-provider.test.ts @@ -16,6 +16,10 @@ function makeToolUse(): string { }); } +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'); @@ -51,6 +55,97 @@ test('CursorProvider discovers transcripts, emits session start + events', async 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'); diff --git a/server/src/providers/cursor-provider.ts b/server/src/providers/cursor-provider.ts index c8f1d09..5537fed 100644 --- a/server/src/providers/cursor-provider.ts +++ b/server/src/providers/cursor-provider.ts @@ -15,6 +15,12 @@ export interface CursorProviderOptions { 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 { @@ -33,6 +39,7 @@ export class CursorProvider implements SessionProvider { 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(); @@ -45,6 +52,7 @@ export class CursorProvider implements SessionProvider { 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 { @@ -129,7 +137,18 @@ export class CursorProvider implements SessionProvider { 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() }); + // 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,