Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A Claude Code plugin that delegates coding tasks from Claude to the Cursor CLI (
2. **No build step.** No TypeScript source, no bundler, no `dist/`. `scripts/*.mjs` IS the ship artefact. If you find yourself wanting one, something has gone wrong with the approach. (`npm run typecheck` runs `tsc --checkJs --noEmit` over the JSDoc annotations in `scripts/lib/` — that is a dev-time check like eslint, not a build step; `typescript` stays a devDependency.)
3. **Slash command scripts live under `plugins/cursor/scripts/<cmd>.mjs`.** Their wrappers at `plugins/cursor/commands/<cmd>.md` must use `node "${CLAUDE_PLUGIN_ROOT}/scripts/<cmd>.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/<repo-hash>/`.** Never break that layout; users point scripts at those files when reporting bugs.
5. **Jobs are persisted under `<state-root>/jobs/<repo-hash>/`.** 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.

Expand Down Expand Up @@ -46,7 +46,8 @@ Plus a **Constraints** block that forbids: touching files outside the list, rena

## Where things live

- `plugins/cursor/scripts/<cmd>.mjs` — command entrypoints (10; `adversarial-review` has no script of its own — it reuses `review.mjs --adversarial`).
- `plugins/cursor/scripts/<cmd>.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, kill, parse, cursor, git, invoked, plan, hints, md).
- `plugins/cursor/commands/*.md` — slash command wrappers.
- `plugins/cursor/agents/cursor-runner.md` — the handoff subagent prompt.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 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.
- **`npm run typecheck`** — `tsc --checkJs --noEmit` over the JSDoc annotations in `scripts/lib/` (`tsconfig.check.json`), wired into CI. Dev-time only: `typescript` is a devDependency, nothing is compiled, `.mjs` stays the ship artefact. The first run surfaced (and this change fixes) two real annotation gaps: `collectReviewContext`'s `mode` was inferred as plain `string` against the declared `'working-tree'|'branch'` union, and `walkToolUses` accessed properties on a value narrowed only to `object`.

### Changed

- **State root honours `CLAUDE_PLUGIN_DATA`.** Fresh installs store jobs under Claude Code's plugin data dir (`CLAUDE_PLUGIN_DATA/state/jobs/<repo-hash>/`), 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/<repo-hash>/` layout is unchanged.
- **`composer-prompting` skill split into SKILL.md + `references/`** (progressive disclosure, mirroring codex's `gpt-5-4-prompting` layout). `SKILL.md` keeps the always-relevant spine (when to use, repo grounding, assembly checklist); the detail moved to `references/prompt-anatomy.md` (five sections + guardrails, now with a full worked example), `references/model-selection.md` (escalation ladder, chunking, resume-vs-fresh), and the new `references/composer-antipatterns.md` (six prompt shapes that reliably produce bad Composer runs, adapted from codex's anti-patterns).

### Fixed
Expand Down
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>`). 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=<id>`). 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
Expand Down Expand Up @@ -315,9 +316,18 @@ Shortcut for `/cursor:delegate --resume <task...>`. 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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/cursor/commands/result.md
Original file line number Diff line number Diff line change
@@ -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:*)
---

Expand Down
2 changes: 1 addition & 1 deletion plugins/cursor/commands/setup.md
Original file line number Diff line number Diff line change
@@ -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:*)
---

Expand Down
2 changes: 1 addition & 1 deletion plugins/cursor/commands/status.md
Original file line number Diff line number Diff line change
@@ -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:*)
---
Expand Down
27 changes: 27 additions & 0 deletions plugins/cursor/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
}
27 changes: 27 additions & 0 deletions plugins/cursor/scripts/lib/jobs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { join } from 'node:path';
import { killTree } from './kill.mjs';
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
*/
Expand All @@ -32,6 +38,7 @@ import { ensureDir, jobsDir, logsDir } from './paths.mjs';
* @property {string[]=} filesTouched
* @property {boolean=} background
* @property {boolean=} cloud
* @property {string=} sessionId
*/

/**
Expand All @@ -42,6 +49,7 @@ import { ensureDir, jobsDir, logsDir } from './paths.mjs';
* @property {string} model
* @property {boolean=} background
* @property {boolean=} cloud
* @property {string=} sessionId
*/

/**
Expand Down Expand Up @@ -83,6 +91,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,
Expand All @@ -94,6 +105,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;
Expand Down Expand Up @@ -248,6 +260,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[]}
Expand Down
12 changes: 10 additions & 2 deletions plugins/cursor/scripts/lib/paths.mjs
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] pluginHome() calls existsSync(legacy) on every invocation, and it's invoked frequently (jobsDir/logsDir/ensureDir call it repeatedly per command run). Not a correctness issue, just a small avoidable stat() per call.

Suggestion: Not worth blocking on — could memoize per-process if it ever shows up as a hot path.

// 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;
}

/**
Expand Down
8 changes: 7 additions & 1 deletion plugins/cursor/scripts/result.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function render(job) {
* @returns {Promise<number>}
*/
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);
Expand All @@ -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`,
Expand Down
105 changes: 105 additions & 0 deletions plugins/cursor/scripts/session-hook.mjs
Original file line number Diff line number Diff line change
@@ -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<string, unknown>} */
function readHookInput() {
try {
const raw = readFileSync(0, 'utf8').trim();
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}

/**
* @param {Record<string, unknown>} 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<string, unknown>} input
* @returns {Promise<number>} 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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] handleSessionEnd resolves only one repo (via repoRoot(cwd) at hook-invocation time) and cancels running jobs there. If the same Claude Code session delegated work in more than one git repo (e.g. the user cd'd into a different project mid-session), jobs left running in those other repos are never cancelled when the session ends.

Suggestion: Either document this as a known limitation (single-repo-per-session assumption) in README's Session lifecycle section, or track touched repos per session if multi-repo sessions are a supported use case.

const mine = listJobs(root).filter((j) => j.status === 'running' && j.sessionId === sessionId);
await Promise.all(mine.map((j) => cancelJob(root, j.id, 3_000)));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] handleSessionEnd resolves repoRoot from the hook's cwd at session end and only cancels running jobs whose repoPath hashes to that same root. If a session delegates a job in one repo and the session's cwd differs at SessionEnd time (or the hook cwd doesn't match the repo the job was created against), that job is silently never cancelled despite carrying this session's id.

Suggestion: Consider scanning across all repo-hash directories under the state root for jobs matching the session id, or document this single-repo assumption explicitly in AGENTS.md/CHANGELOG so it's a known limitation rather than a surprise.

return mine.length;
}

/**
* @param {string[]} rawArgv
* @returns {Promise<number>}
*/
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);
});
}
Loading
Loading