From 1250a37b4d7f0ec7ae42f1912658e7bc2ba5a01a Mon Sep 17 00:00:00 2001 From: Tomas Grasl Date: Tue, 28 Jul 2026 15:30:34 +0200 Subject: [PATCH] feat(session): session lifecycle hooks, CLAUDE_PLUGIN_DATA state root, --json output Modelled on openai/codex-plugin-cc's session tracking, adapted to the zero-deps runtime: - hooks/hooks.json + scripts/session-hook.mjs: SessionStart exports the Claude session id (and CLAUDE_PLUGIN_DATA) into CLAUDE_ENV_FILE; SessionEnd cancels the session's still-running jobs so a closed session no longer leaves detached workers and cursor-agent running unattended. Other sessions' jobs and unattributed jobs are never touched. - createJob stamps sessionId from CURSOR_PLUGIN_CC_SESSION_ID - /cursor:status default view scoped to the current session (plus unattributed jobs) with a hidden-rows hint; --all lifts scope and cap; new lib/jobs.mjs#filterJobsForSession - --json on status/result/setup for scripting (raw job records / doctor report); setup's doctor split into gather + render - pluginHome(): CURSOR_PLUGIN_CC_HOME > existing ~/.cursor-plugin-cc > CLAUDE_PLUGIN_DATA/state > ~/.cursor-plugin-cc; jobs// layout unchanged - tests: session stamping, session filter, hook handlers (env-file write, selective cancel), state-root precedence - docs: README session-lifecycle section, AGENTS.md state-root rule, CHANGELOG Co-Authored-By: Claude Fable 5 --- AGENTS.md | 5 +- CHANGELOG.md | 12 +++ README.md | 24 +++-- plugins/cursor/commands/result.md | 2 +- plugins/cursor/commands/setup.md | 2 +- plugins/cursor/commands/status.md | 2 +- plugins/cursor/hooks/hooks.json | 27 ++++++ plugins/cursor/scripts/lib/jobs.mjs | 27 ++++++ plugins/cursor/scripts/lib/paths.mjs | 12 ++- plugins/cursor/scripts/result.mjs | 8 +- plugins/cursor/scripts/session-hook.mjs | 105 +++++++++++++++++++++ plugins/cursor/scripts/setup.mjs | 48 +++++++--- plugins/cursor/scripts/status.mjs | 27 ++++-- plugins/cursor/tests/helpers.mjs | 16 ++++ plugins/cursor/tests/paths.test.mjs | 47 ++++++++++ plugins/cursor/tests/session.test.mjs | 120 ++++++++++++++++++++++++ 16 files changed, 451 insertions(+), 33 deletions(-) create mode 100644 plugins/cursor/hooks/hooks.json create mode 100644 plugins/cursor/scripts/session-hook.mjs create mode 100644 plugins/cursor/tests/session.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 26b8e88..f8a840d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ A Claude Code plugin that delegates coding tasks from Claude to the Cursor CLI ( 2. **No build step.** No TypeScript, no bundler, no `dist/`. `scripts/*.mjs` IS the ship artefact. If you find yourself wanting one, something has gone wrong with the approach. 3. **Slash command scripts live under `plugins/cursor/scripts/.mjs`.** Their wrappers at `plugins/cursor/commands/.md` must use `node "${CLAUDE_PLUGIN_ROOT}/scripts/.mjs" -- "$ARGUMENTS"` with quoted `$ARGUMENTS` — unquoted breaks under zsh on any prompt containing `?`, `*`, or `@`. Exception: `review.md` and `adversarial-review.md` are model-orchestrated (they estimate the diff and ask wait-vs-background before running), so they give Claude the `node …` command in a fenced block rather than an auto-executing `!` line, and `adversarial-review` reuses `review.mjs --adversarial` instead of shipping its own script. 4. **`Bash(node:*)` is the only permission pattern used in `allowed-tools`.** Do not invent path-based patterns — Claude Code does not expand `${CLAUDE_PLUGIN_ROOT}` inside `allowed-tools`. Exception: the two estimate-first review commands additionally list `Bash(git:*)`, `AskUserQuestion`, and `Read, Glob, Grep` for the size-estimate/ask step — those are tool-name patterns, not path-based ones, so they are fine. -5. **Jobs are persisted under `~/.cursor-plugin-cc/jobs//`.** Never break that layout; users point scripts at those files when reporting bugs. +5. **Jobs are persisted under `/jobs//`.** Never break that layout; users point scripts at those files when reporting bugs. The state root resolves in `lib/paths.mjs#pluginHome`: `CURSOR_PLUGIN_CC_HOME` env → an existing `~/.cursor-plugin-cc` (legacy installs keep their history) → `CLAUDE_PLUGIN_DATA/state` (fresh installs) → `~/.cursor-plugin-cc`. 6. **Language: everything in this repo is English.** Code, comments, commit messages, docs, PR bodies, issue titles. The plugin does not impose a language policy on target repos — `cursor-runner` reads target-repo conventions — but this repo itself is English-only. 7. **Do not impose conventions on target repos.** The `cursor-runner` subagent reads `AGENTS.md` / `.cursor/rules` / existing code in whatever repo the user is working in and tells Cursor to match THAT style. When editing the subagent, do not hardcode English / Prettier / whatever. @@ -46,7 +46,8 @@ Plus a **Constraints** block that forbids: touching files outside the list, rena ## Where things live -- `plugins/cursor/scripts/.mjs` — command entrypoints (10; `adversarial-review` has no script of its own — it reuses `review.mjs --adversarial`). +- `plugins/cursor/scripts/.mjs` — command entrypoints (10; `adversarial-review` has no script of its own — it reuses `review.mjs --adversarial`). `session-hook.mjs` is the one non-command script: the SessionStart/SessionEnd hook entrypoint. +- `plugins/cursor/hooks/hooks.json` — Claude Code hook registration (session lifecycle). - `plugins/cursor/scripts/lib/*.mjs` — shared helpers (run, id, args, paths, jobs, parse, cursor, git, invoked, plan, hints, md). - `plugins/cursor/commands/*.md` — slash command wrappers. - `plugins/cursor/agents/cursor-runner.md` — the handoff subagent prompt. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8430ce2..f119a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- **Session lifecycle hooks** (`hooks/hooks.json` + `scripts/session-hook.mjs`), modelled on `openai/codex-plugin-cc`. **SessionStart** exports the Claude session id (and `CLAUDE_PLUGIN_DATA`) into the session env via `CLAUDE_ENV_FILE`, so every job records which session started it (`sessionId` on the job record, stamped in `createJob`). **SessionEnd** cancels the session's still-running jobs — a closed Claude session no longer leaves detached workers and `cursor-agent` running unattended. Jobs from other sessions or without a session stamp are never touched. +- **`/cursor:status` scopes its default view to the current session.** The no-arg table now shows this session's jobs plus unattributed ones, with a hint line when rows are hidden; `--all` lifts both the session scope and the 10-row cap. New `lib/jobs.mjs#filterJobsForSession`. +- **`--json` on `/cursor:status`, `/cursor:result`, `/cursor:setup`.** Status and result emit the raw job record(s); setup emits the full doctor report (`checks[].ok`, `allOk`). Meant for scripting and future hooks that branch on job state instead of parsing Markdown. + +### Changed + +- **State root honours `CLAUDE_PLUGIN_DATA`.** Fresh installs store jobs under Claude Code's plugin data dir (`CLAUDE_PLUGIN_DATA/state/jobs//`), which is cleaned up with the plugin. Existing installs keep `~/.cursor-plugin-cc` — an existing legacy dir always wins so job history is never stranded. `CURSOR_PLUGIN_CC_HOME` still overrides everything. The `jobs//` layout is unchanged. + ## 0.4.0 — /cursor:adversarial-review + estimate-first reviews + composer-prompting skill Ported from upstream [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc) (whose `/codex:adversarial-review`, estimate-first review flow, and `gpt-5-4-prompting` skill this release mirrors), adapted to the Cursor CLI. diff --git a/README.md b/README.md index dda6498..bc53a70 100644 --- a/README.md +++ b/README.md @@ -275,18 +275,19 @@ Restart Cursor so `cursor-agent` picks up the new MCP. `--isolated` makes each r Verify with `/cursor:setup --doctor` — it now lists every MCP `cursor-agent` can see and whether each is loaded. -### `/cursor:status [job-id] [--all]` +### `/cursor:status [job-id] [--all] [--json]` -Without args, shows the last 10 jobs for this repository as a table. With an id, shows the full job record including the Cursor chat id (so you can resume manually with `cursor-agent --resume=`). Pass `--all` to drop the 10-row limit. +Without args, shows the jobs for this repository as a table — scoped to the current Claude Code session (plus jobs that predate session tracking), capped at 10 rows. With an id, shows the full job record including the Cursor chat id (so you can resume manually with `cursor-agent --resume=`). Pass `--all` to lift both the session scope and the row cap. Pass `--json` to get the raw job record(s) for scripting. ``` /cursor:status /cursor:status V1StGXR8_Z +/cursor:status --all ``` -### `/cursor:result [job-id]` +### `/cursor:result [job-id] [--json]` -Prints the final summary of a finished job. Defaults to the most recent one for this repo. +Prints the final summary of a finished job. Defaults to the most recent one for this repo. `--json` prints the raw job record (status, summary, filesTouched, exit code, chat id) instead of Markdown. ``` /cursor:result @@ -315,9 +316,18 @@ Shortcut for `/cursor:delegate --resume `. Without a task, sends an emp Shells out to `cursor-agent ls` and lists Cursor's own chat sessions for this repo. If that call times out or returns empty, the plugin falls back to its local job registry. -### `/cursor:setup [--doctor] [--print-models] [--install]` +### `/cursor:setup [--doctor] [--print-models] [--install] [--json]` -Runs a quick health-check. `--doctor` produces extended diagnostics (Node version, PATH, `CURSOR_API_KEY` presence masked, jobs dir writability, cursor-agent version). `--print-models` shells out to `cursor-agent --list-models`. `--install` prints the install command but **does not run it** — you must copy-paste it yourself. +Runs a quick health-check. `--doctor` produces extended diagnostics (Node version, PATH, `CURSOR_API_KEY` presence masked, jobs dir writability, cursor-agent version). `--print-models` shells out to `cursor-agent --list-models`. `--install` prints the install command but **does not run it** — you must copy-paste it yourself. `--json` emits the full doctor report as JSON (`checks[].ok`, `allOk`) for scripting. + +## Session lifecycle + +The plugin registers two Claude Code hooks (`hooks/hooks.json`): + +- **SessionStart** exports the Claude session id into the session's environment, so every job created from that session is stamped with it. `/cursor:status` uses the stamp to scope its default view to *your* jobs — parallel Claude sessions in the same repo stop seeing each other's runs (use `--all` for everything). +- **SessionEnd** cancels the session's still-running jobs. Background delegates run as detached workers, so without this a closed Claude session would leave `cursor-agent` running unattended. Jobs from other sessions — or jobs with no session stamp — are left alone. + +If you *want* a run to outlive the session, start it outside the hook's reach (e.g. `node scripts/delegate.mjs` from a plain terminal) — jobs without a session stamp are never auto-cancelled. ## The two-phase loop @@ -417,7 +427,7 @@ The task file stays in `tasks/` as a durable record — the contract between pla | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `CURSOR_API_KEY` | Forwarded to `cursor-agent`. Optional — `cursor-agent login` is usually enough. | | `CURSOR_AGENT_BIN` | Override binary path (used by the test suite). | -| `CURSOR_PLUGIN_CC_HOME` | Override the jobs-registry root (default `~/.cursor-plugin-cc`). | +| `CURSOR_PLUGIN_CC_HOME` | Override the jobs-registry root. Default: an existing `~/.cursor-plugin-cc` if present, else Claude Code's plugin data dir (`CLAUDE_PLUGIN_DATA/state`), else `~/.cursor-plugin-cc`. | | `CURSOR_PLUGIN_CC_DEFAULT_MODEL` | Default `--model` when none is passed. Accepts the same aliases as `--model` (e.g. `composer`, `opus`). Falls back to `auto`. | A repo-local `.cursor-plugin-cc.json` is on the roadmap for overriding the default model per repo; until then, set `--model` per invocation or pin `CURSOR_PLUGIN_CC_DEFAULT_MODEL` in your shell. diff --git a/plugins/cursor/commands/result.md b/plugins/cursor/commands/result.md index 82f600d..e4474db 100644 --- a/plugins/cursor/commands/result.md +++ b/plugins/cursor/commands/result.md @@ -1,6 +1,6 @@ --- description: Print the final output of a finished Cursor job (most recent by default). -argument-hint: '[job-id]' +argument-hint: '[job-id] [--json]' allowed-tools: Bash(node:*) --- diff --git a/plugins/cursor/commands/setup.md b/plugins/cursor/commands/setup.md index 7304065..4bfe1d2 100644 --- a/plugins/cursor/commands/setup.md +++ b/plugins/cursor/commands/setup.md @@ -1,6 +1,6 @@ --- description: Health-check Cursor CLI, list models, or guide installation. -argument-hint: '[--doctor] [--print-models] [--install]' +argument-hint: '[--doctor] [--print-models] [--install] [--json]' allowed-tools: Bash(node:*) --- diff --git a/plugins/cursor/commands/status.md b/plugins/cursor/commands/status.md index 376f591..fe5bb74 100644 --- a/plugins/cursor/commands/status.md +++ b/plugins/cursor/commands/status.md @@ -1,6 +1,6 @@ --- description: Show active and recent Cursor jobs for this repository. -argument-hint: '[job-id] [--all]' +argument-hint: '[job-id] [--all] [--json]' disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/cursor/hooks/hooks.json b/plugins/cursor/hooks/hooks.json new file mode 100644 index 0000000..684d30a --- /dev/null +++ b/plugins/cursor/hooks/hooks.json @@ -0,0 +1,27 @@ +{ + "description": "Session lifecycle for Cursor jobs: stamp jobs with the owning Claude session, cancel the session's running jobs on exit.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-hook.mjs\" SessionStart", + "timeout": 5 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-hook.mjs\" SessionEnd", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/cursor/scripts/lib/jobs.mjs b/plugins/cursor/scripts/lib/jobs.mjs index 0a94d9b..77226fe 100644 --- a/plugins/cursor/scripts/lib/jobs.mjs +++ b/plugins/cursor/scripts/lib/jobs.mjs @@ -10,6 +10,12 @@ import { import { join } from 'node:path'; import { ensureDir, jobsDir, logsDir } from './paths.mjs'; +/** + * Set by the SessionStart hook (via CLAUDE_ENV_FILE), so every job created + * from a Claude Code session carries the id of the session that started it. + */ +export const SESSION_ID_ENV = 'CURSOR_PLUGIN_CC_SESSION_ID'; + /** * @typedef {'running'|'done'|'failed'|'cancelled'} JobStatus */ @@ -31,6 +37,7 @@ import { ensureDir, jobsDir, logsDir } from './paths.mjs'; * @property {string[]=} filesTouched * @property {boolean=} background * @property {boolean=} cloud + * @property {string=} sessionId */ /** @@ -41,6 +48,7 @@ import { ensureDir, jobsDir, logsDir } from './paths.mjs'; * @property {string} model * @property {boolean=} background * @property {boolean=} cloud + * @property {string=} sessionId */ /** @@ -82,6 +90,9 @@ function atomicWrite(target, data) { export function createJob(init) { ensureDir(jobsDir(init.repoPath)); ensureDir(logsDir(init.repoPath)); + // Stamp the owning Claude session so /cursor:status can scope its default + // view and the SessionEnd hook knows which running jobs belong to it. + const sessionId = init.sessionId ?? process.env[SESSION_ID_ENV]; /** @type {JobRecord} */ const record = { id: init.id, @@ -93,6 +104,7 @@ export function createJob(init) { rawLogPath: rawLogPath(init.repoPath, init.id), ...(init.background ? { background: true } : {}), ...(init.cloud ? { cloud: true } : {}), + ...(sessionId && sessionId.trim() ? { sessionId: sessionId.trim() } : {}), }; atomicWrite(jobFilePath(init.repoPath, init.id), JSON.stringify(record, null, 2)); return record; @@ -252,6 +264,21 @@ export async function cancelJob(repoPath, id, graceMs = 5_000) { }); } +/** + * Jobs a given Claude session should see by default: its own, plus records + * with no session stamp (pre-hook jobs, or runs outside Claude Code) — those + * cannot be attributed, so hiding them would make them undiscoverable. + * Without a session id, everything is visible. + * + * @param {JobRecord[]} jobs + * @param {string|undefined} sessionId + * @returns {JobRecord[]} + */ +export function filterJobsForSession(jobs, sessionId) { + if (!sessionId) return jobs; + return jobs.filter((j) => !j.sessionId || j.sessionId === sessionId); +} + /** * @param {string} repoPath * @returns {JobRecord[]} diff --git a/plugins/cursor/scripts/lib/paths.mjs b/plugins/cursor/scripts/lib/paths.mjs index d404280..2b68bb0 100644 --- a/plugins/cursor/scripts/lib/paths.mjs +++ b/plugins/cursor/scripts/lib/paths.mjs @@ -1,12 +1,20 @@ import { createHash } from 'node:crypto'; -import { mkdirSync, realpathSync } from 'node:fs'; +import { existsSync, mkdirSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; export function pluginHome() { const fromEnv = process.env.CURSOR_PLUGIN_CC_HOME; if (fromEnv && fromEnv.trim().length > 0) return resolve(fromEnv); - return join(homedir(), '.cursor-plugin-cc'); + // Existing installs keep their state where it already lives — the harness + // starting to provide a data dir must never strand previous job history. + const legacy = join(homedir(), '.cursor-plugin-cc'); + if (existsSync(legacy)) return legacy; + // Fresh installs prefer the Claude-Code-managed plugin data dir: it is + // cleaned up with the plugin instead of leaving state behind in $HOME. + const pluginData = process.env.CLAUDE_PLUGIN_DATA; + if (pluginData && pluginData.trim().length > 0) return join(resolve(pluginData), 'state'); + return legacy; } /** diff --git a/plugins/cursor/scripts/result.mjs b/plugins/cursor/scripts/result.mjs index 675f20a..3da2f62 100644 --- a/plugins/cursor/scripts/result.mjs +++ b/plugins/cursor/scripts/result.mjs @@ -37,7 +37,7 @@ function render(job) { * @returns {Promise} */ export async function main(rawArgv) { - const { positional } = parseCommandArgv(rawArgv); + const { positional, flags } = parseCommandArgv(rawArgv, ['json']); const root = await repoRoot(process.cwd()); const id = positional[0]; const job = id ? readJob(root, id) : mostRecentFinishedJob(root); @@ -47,6 +47,12 @@ export async function main(rawArgv) { ); return 1; } + if (flags['json']) { + // The record carries status, summary, filesTouched, exitCode, chat id — + // callers (hooks, scripts) branch on those instead of parsing Markdown. + process.stdout.write(JSON.stringify(job, null, 2) + '\n'); + return 0; + } if (job.status === 'running') { process.stdout.write( `Job \`${job.id}\` is still running. Use \`/cursor:status ${job.id}\` to monitor it.\n`, diff --git a/plugins/cursor/scripts/session-hook.mjs b/plugins/cursor/scripts/session-hook.mjs new file mode 100644 index 0000000..5ffd7ae --- /dev/null +++ b/plugins/cursor/scripts/session-hook.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// Claude Code session lifecycle hook (SessionStart / SessionEnd). +// +// SessionStart: exports the session id (and CLAUDE_PLUGIN_DATA, which slash +// command invocations do not receive automatically) into CLAUDE_ENV_FILE so +// every subsequent script run in the session can stamp jobs with the owning +// session and resolve the harness-managed state dir. +// +// SessionEnd: cancels THIS session's still-running jobs. Background workers +// are detached, so without this a closed Claude session leaves cursor-agent +// running unattended. Jobs from other sessions — or with no session stamp — +// are deliberately left alone. + +import { appendFileSync, readFileSync } from 'node:fs'; +import { repoRoot } from './lib/git.mjs'; +import { SESSION_ID_ENV, cancelJob, listJobs } from './lib/jobs.mjs'; +import { invokedAsScript as __isScript } from './lib/invoked.mjs'; + +const PLUGIN_DATA_ENV = 'CLAUDE_PLUGIN_DATA'; + +/** @param {string} value */ +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + +/** + * @param {string} name + * @param {string|undefined} value + */ +function appendEnvVar(name, value) { + const envFile = process.env.CLAUDE_ENV_FILE; + if (!envFile || !value) return; + appendFileSync(envFile, `export ${name}=${shellQuote(value)}\n`, 'utf8'); +} + +/** @returns {Record} */ +function readHookInput() { + try { + const raw = readFileSync(0, 'utf8').trim(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +/** + * @param {Record} input + */ +export function handleSessionStart(input) { + const sessionId = typeof input.session_id === 'string' ? input.session_id : undefined; + appendEnvVar(SESSION_ID_ENV, sessionId); + appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]); +} + +/** + * @param {Record} input + * @returns {Promise} number of jobs cancelled + */ +export async function handleSessionEnd(input) { + const sessionId = + (typeof input.session_id === 'string' && input.session_id) || process.env[SESSION_ID_ENV]; + if (!sessionId) return 0; + const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd(); + const root = await repoRoot(cwd); + const mine = listJobs(root).filter((j) => j.status === 'running' && j.sessionId === sessionId); + await Promise.all(mine.map((j) => cancelJob(root, j.id, 3_000))); + return mine.length; +} + +/** + * @param {string[]} rawArgv + * @returns {Promise} + */ +export async function main(rawArgv) { + const input = readHookInput(); + const eventName = rawArgv[0] ?? input.hook_event_name ?? ''; + if (eventName === 'SessionStart') { + handleSessionStart(input); + return 0; + } + if (eventName === 'SessionEnd') { + const cancelled = await handleSessionEnd(input); + if (cancelled > 0) { + process.stderr.write( + `cursor-plugin-cc: cancelled ${cancelled} running job(s) on session end.\n`, + ); + } + return 0; + } + return 0; +} + +const invokedAsScript = __isScript(import.meta.url); + +if (invokedAsScript) { + main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write( + `session-hook failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + // Never block the session over hook housekeeping. + process.exit(0); + }); +} diff --git a/plugins/cursor/scripts/setup.mjs b/plugins/cursor/scripts/setup.mjs index 1881813..128f883 100644 --- a/plugins/cursor/scripts/setup.mjs +++ b/plugins/cursor/scripts/setup.mjs @@ -41,12 +41,9 @@ function maskKey(value) { return `${value.slice(0, 4)}…${value.slice(-4)}`; } -async function doctor() { - const lines = ['### /cursor:setup --doctor\n']; +async function gatherDoctor() { + /** @type {Array<[string, {ok: boolean, detail: string}]>} */ const checks = []; - lines.push(`- Node: ${process.version}`); - lines.push(`- Platform: ${process.platform} (${process.arch})`); - lines.push(`- Plugin home: \`${pluginHome()}\``); let bin = ''; try { @@ -83,6 +80,37 @@ async function doctor() { { ok: true, detail: apiKey ? `set (${maskKey(apiKey)})` : 'not set (using local session)' }, ]); + const mcps = bin ? await listConfiguredMcps() : []; + + // The CURSOR_API_KEY check is already `ok:true` whether or not the key is + // set, so a literal `r.ok` is correct here — a stray "not set" substring in + // some other check's stderr must not mask a real failure. + const allOk = checks.every(([, r]) => r.ok); + return { bin, checks, mcps, allOk }; +} + +async function doctor(asJson = false) { + const { bin, checks, mcps, allOk } = await gatherDoctor(); + + if (asJson) { + const payload = { + node: process.version, + platform: process.platform, + arch: process.arch, + pluginHome: pluginHome(), + checks: checks.map(([name, r]) => ({ name, ok: r.ok, detail: r.detail })), + mcps, + allOk, + }; + process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); + return allOk ? 0 : 1; + } + + const lines = ['### /cursor:setup --doctor\n']; + lines.push(`- Node: ${process.version}`); + lines.push(`- Platform: ${process.platform} (${process.arch})`); + lines.push(`- Plugin home: \`${pluginHome()}\``); + lines.push(''); for (const [name, r] of checks) { const icon = r.ok ? '✓' : '✗'; @@ -90,7 +118,6 @@ async function doctor() { } if (bin) { - const mcps = await listConfiguredMcps(); lines.push(''); lines.push('**Configured Cursor MCPs:**'); if (mcps.length === 0) { @@ -103,10 +130,6 @@ async function doctor() { } } - // The CURSOR_API_KEY check is already `ok:true` whether or not the key is - // set, so a literal `r.ok` is correct here — a stray "not set" substring in - // some other check's stderr must not mask a real failure. - const allOk = checks.every(([, r]) => r.ok); lines.push(''); lines.push(allOk ? 'All checks passed.' : 'Some checks failed — see above.'); process.stdout.write(lines.join('\n') + '\n'); @@ -170,7 +193,10 @@ async function baseCheck() { * @returns {Promise} */ export async function main(rawArgv) { - const { flags } = parseCommandArgv(rawArgv, ['doctor', 'print-models', 'install']); + const { flags } = parseCommandArgv(rawArgv, ['doctor', 'print-models', 'install', 'json']); + // --json always emits the full structured doctor report — hooks and scripts + // branch on `checks[].ok` / `allOk` instead of parsing Markdown. + if (flags['json']) return doctor(true); if (flags['doctor']) return doctor(); if (flags['print-models'] || flags['printModels']) return printModels(); if (flags['install']) return maybeInstall(); diff --git a/plugins/cursor/scripts/status.mjs b/plugins/cursor/scripts/status.mjs index 87ad103..b0289bb 100644 --- a/plugins/cursor/scripts/status.mjs +++ b/plugins/cursor/scripts/status.mjs @@ -2,7 +2,7 @@ import { parseCommandArgv } from './lib/args.mjs'; import { repoRoot } from './lib/git.mjs'; import { jobNotFoundMessage } from './lib/hints.mjs'; -import { listJobs, readJob } from './lib/jobs.mjs'; +import { SESSION_ID_ENV, filterJobsForSession, listJobs, readJob } from './lib/jobs.mjs'; import { mdCell } from './lib/md.mjs'; function age(iso) { @@ -77,7 +77,8 @@ function renderDetail(r) { * @returns {Promise} */ export async function main(rawArgv) { - const { positional, flags } = parseCommandArgv(rawArgv, ['all']); + const { positional, flags } = parseCommandArgv(rawArgv, ['all', 'json']); + const asJson = Boolean(flags['json']); const root = await repoRoot(process.cwd()); const id = positional[0]; if (id) { @@ -86,14 +87,26 @@ export async function main(rawArgv) { process.stderr.write(jobNotFoundMessage(id)); return 1; } - process.stdout.write(renderDetail(job)); + process.stdout.write(asJson ? JSON.stringify(job, null, 2) + '\n' : renderDetail(job)); + return 0; + } + // Default view is scoped to the current Claude session (plus unattributed + // jobs); --all lifts both the session scope and the 10-row cap. + const all = listJobs(root); + const rows = flags['all'] + ? all + : filterJobsForSession(all, process.env[SESSION_ID_ENV]).slice(0, 10); + if (asJson) { + process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; } - const limit = flags['all'] ? undefined : 10; - const listOpts = {}; - if (typeof limit === 'number') listOpts.limit = limit; - const rows = listJobs(root, listOpts); process.stdout.write(renderTable(rows)); + const hidden = all.length - rows.length; + if (hidden > 0) { + process.stdout.write( + `\n_${hidden} job(s) hidden (other sessions or older) — use \`--all\` to list everything._\n`, + ); + } return 0; } diff --git a/plugins/cursor/tests/helpers.mjs b/plugins/cursor/tests/helpers.mjs index 82cc2b4..62bef70 100644 --- a/plugins/cursor/tests/helpers.mjs +++ b/plugins/cursor/tests/helpers.mjs @@ -10,6 +10,22 @@ export function makeTempHome() { }; } +export function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export async function waitForDeath(pid, timeoutMs = 3_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && isAlive(pid)) { + await new Promise((r) => setTimeout(r, 50)); + } +} + export const STUB_BIN = new URL('./fixtures/cursor-agent-stub.mjs', import.meta.url).pathname; export const HAPPY_FIXTURE = new URL('./fixtures/cursor-events/happy-path.ndjson', import.meta.url) .pathname; diff --git a/plugins/cursor/tests/paths.test.mjs b/plugins/cursor/tests/paths.test.mjs index 3d4dca2..be017dc 100644 --- a/plugins/cursor/tests/paths.test.mjs +++ b/plugins/cursor/tests/paths.test.mjs @@ -1,3 +1,5 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { pluginHome, repoHash, jobsDir } from '../scripts/lib/paths.mjs'; import { makeTempHome } from './helpers.mjs'; @@ -33,4 +35,49 @@ describe('paths', () => { expect(dir.startsWith(tmp.dir)).toBe(true); expect(dir).toContain('jobs'); }); + + describe('state-root resolution without the env override', () => { + // os.homedir() reads $HOME on POSIX, so point it at a temp dir to control + // whether the legacy ~/.cursor-plugin-cc exists. + let fakeHome; + const prevHOME = process.env.HOME; + const prevPluginData = process.env.CLAUDE_PLUGIN_DATA; + + beforeEach(() => { + fakeHome = makeTempHome(); + process.env.HOME = fakeHome.dir; + delete process.env.CURSOR_PLUGIN_CC_HOME; + delete process.env.CLAUDE_PLUGIN_DATA; + }); + + afterEach(() => { + if (prevHOME === undefined) delete process.env.HOME; + else process.env.HOME = prevHOME; + if (prevPluginData === undefined) delete process.env.CLAUDE_PLUGIN_DATA; + else process.env.CLAUDE_PLUGIN_DATA = prevPluginData; + fakeHome.cleanup(); + }); + + it.skipIf(process.platform === 'win32')('defaults to ~/.cursor-plugin-cc', () => { + expect(pluginHome()).toBe(join(fakeHome.dir, '.cursor-plugin-cc')); + }); + + it.skipIf(process.platform === 'win32')( + 'prefers CLAUDE_PLUGIN_DATA/state on a fresh install', + () => { + process.env.CLAUDE_PLUGIN_DATA = join(fakeHome.dir, 'plugin-data'); + expect(pluginHome()).toBe(join(fakeHome.dir, 'plugin-data', 'state')); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'keeps an existing legacy dir even when CLAUDE_PLUGIN_DATA is set', + () => { + const legacy = join(fakeHome.dir, '.cursor-plugin-cc'); + mkdirSync(legacy, { recursive: true }); + process.env.CLAUDE_PLUGIN_DATA = join(fakeHome.dir, 'plugin-data'); + expect(pluginHome()).toBe(legacy); + }, + ); + }); }); diff --git a/plugins/cursor/tests/session.test.mjs b/plugins/cursor/tests/session.test.mjs new file mode 100644 index 0000000..1f7a89d --- /dev/null +++ b/plugins/cursor/tests/session.test.mjs @@ -0,0 +1,120 @@ +import { spawn } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + SESSION_ID_ENV, + createJob, + filterJobsForSession, + listJobs, + readJob, + updateJob, +} from '../scripts/lib/jobs.mjs'; +import { handleSessionEnd, handleSessionStart } from '../scripts/session-hook.mjs'; +import { isAlive, makeTempHome, waitForDeath } from './helpers.mjs'; + +describe('session lifecycle', () => { + let tmp; + const prevHome = process.env.CURSOR_PLUGIN_CC_HOME; + const prevSession = process.env[SESSION_ID_ENV]; + const prevEnvFile = process.env.CLAUDE_ENV_FILE; + const repo = '/tmp/some-repo-path'; + + beforeEach(() => { + tmp = makeTempHome(); + process.env.CURSOR_PLUGIN_CC_HOME = tmp.dir; + delete process.env[SESSION_ID_ENV]; + delete process.env.CLAUDE_ENV_FILE; + }); + + afterEach(() => { + if (prevHome === undefined) delete process.env.CURSOR_PLUGIN_CC_HOME; + else process.env.CURSOR_PLUGIN_CC_HOME = prevHome; + if (prevSession === undefined) delete process.env[SESSION_ID_ENV]; + else process.env[SESSION_ID_ENV] = prevSession; + if (prevEnvFile === undefined) delete process.env.CLAUDE_ENV_FILE; + else process.env.CLAUDE_ENV_FILE = prevEnvFile; + tmp.cleanup(); + }); + + it('createJob stamps the session id from the env', () => { + process.env[SESSION_ID_ENV] = 'sess-abc'; + const job = createJob({ id: 'j1', repoPath: repo, prompt: 'p', model: 'm' }); + expect(job.sessionId).toBe('sess-abc'); + expect(readJob(repo, 'j1')?.sessionId).toBe('sess-abc'); + }); + + it('createJob leaves sessionId unset without the env', () => { + const job = createJob({ id: 'j2', repoPath: repo, prompt: 'p', model: 'm' }); + expect(job.sessionId).toBeUndefined(); + }); + + it('filterJobsForSession keeps own and unattributed jobs', () => { + const jobs = [ + { id: 'mine', sessionId: 's1' }, + { id: 'theirs', sessionId: 's2' }, + { id: 'legacy' }, + ]; + expect(filterJobsForSession(jobs, 's1').map((j) => j.id)).toEqual(['mine', 'legacy']); + expect(filterJobsForSession(jobs, undefined).map((j) => j.id)).toEqual([ + 'mine', + 'theirs', + 'legacy', + ]); + }); + + it('SessionStart exports the session id into CLAUDE_ENV_FILE', () => { + const envFile = join(tmp.dir, 'env.sh'); + writeFileSync(envFile, '', 'utf8'); + process.env.CLAUDE_ENV_FILE = envFile; + handleSessionStart({ session_id: "se'ss-1" }); + const content = readFileSync(envFile, 'utf8'); + // Quoting must survive an embedded single quote. + expect(content).toContain(`export ${SESSION_ID_ENV}='se'"'"'ss-1'`); + }); + + it('SessionStart without CLAUDE_ENV_FILE is a no-op', () => { + expect(() => handleSessionStart({ session_id: 's1' })).not.toThrow(); + }); + + it.skipIf(process.platform === 'win32')( + 'SessionEnd cancels only this session running jobs', + async () => { + const mine = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + const other = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + try { + createJob({ id: 'mine', repoPath: repo, prompt: 'p', model: 'm', sessionId: 's1' }); + updateJob(repo, 'mine', { pid: mine.pid }); + createJob({ id: 'other', repoPath: repo, prompt: 'p', model: 'm', sessionId: 's2' }); + updateJob(repo, 'other', { pid: other.pid }); + createJob({ id: 'finished', repoPath: repo, prompt: 'p', model: 'm', sessionId: 's1' }); + updateJob(repo, 'finished', { status: 'done' }); + + // repo is not a git repo, so repoRoot(cwd) falls back to cwd — pass + // the jobs key path directly. + const cancelled = await handleSessionEnd({ session_id: 's1', cwd: repo }); + expect(cancelled).toBe(1); + expect(readJob(repo, 'mine')?.status).toBe('cancelled'); + expect(readJob(repo, 'other')?.status).toBe('running'); + expect(readJob(repo, 'finished')?.status).toBe('done'); + await waitForDeath(mine.pid); + expect(isAlive(mine.pid)).toBe(false); + expect(isAlive(other.pid)).toBe(true); + expect(listJobs(repo).length).toBe(3); + } finally { + for (const child of [mine, other]) { + if (!child.killed) child.kill('SIGKILL'); + } + } + }, + ); + + it('SessionEnd without a session id does nothing', async () => { + const cancelled = await handleSessionEnd({ cwd: repo }); + expect(cancelled).toBe(0); + }); +});