feat(session): session lifecycle hooks, CLAUDE_PLUGIN_DATA state root, --json output - #18
Conversation
…, --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/<repo-hash>/ 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 <noreply@anthropic.com>
freema
left a comment
There was a problem hiding this comment.
CodeForge Review
Verdict: approve | Score: 8/10
Clean, well-scoped feature PR adding session lifecycle hooks (stamp jobs with the owning Claude session, cancel them on SessionEnd) plus a --json output mode across status/result/setup and a CLAUDE_PLUGIN_DATA-aware state root with legacy-dir fallback. Logic is sound, docs/CHANGELOG are updated consistently with the code, and the new session-hook/paths test suites are solid (all 99 tests pass, eslint clean). Only minor gaps: no direct tests for the new --json branches in status.mjs/result.mjs/setup.mjs, and SessionEnd only cancels jobs in the repo resolved from the hook's cwd, so a session that touched multiple git repos would leave jobs in the other repos running.
Reviewed by CodeForge
| const rows = flags['all'] | ||
| ? all | ||
| : filterJobsForSession(all, process.env[SESSION_ID_ENV]).slice(0, 10); | ||
| if (asJson) { |
There was a problem hiding this comment.
[MINOR] The new --json flag (also added to result.mjs and setup.mjs) has no direct unit test coverage, even though the CHANGELOG/README frame it as the primary scripting-facing feature of this release. Only the underlying data shapes (filterJobsForSession, doctor checks) are tested, not the CLI flag wiring itself.
Suggestion: Add a small test per script asserting
main(['--json'])(or with a job id) writes valid JSON matching the expected job/doctor record shape to stdout.
| (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); |
There was a problem hiding this comment.
[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.
freema
left a comment
There was a problem hiding this comment.
CodeForge Review
Verdict: approve | Score: 8/10
Well-scoped, well-tested feature: session-scoped job tracking via new SessionStart/SessionEnd hooks, a migration-safe state-root change (CLAUDE_PLUGIN_DATA with legacy-dir fallback), and --json output on three commands. Shell-quoting for the env-file export is handled correctly, cancellation only touches the calling session's own jobs, and docs/CHANGELOG/README are updated in lockstep with the code. Typecheck, lint, and the new tests all pass. Main gaps are missing unit tests for the new --json branches in status/result/setup, and a narrow edge case where SessionEnd only cancels jobs in the repo matching the hook's cwd.
Reviewed by CodeForge
| } | ||
| // 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); |
There was a problem hiding this comment.
[MINOR] The new --json branches for /cursor:status, /cursor:result, and /cursor:setup --json have no unit tests (session.test.mjs and paths.test.mjs only cover the session-hook/state-root logic). These are simple branches but they are also the ones future hooks/scripts will script against, so a schema regression would be silent.
Suggestion: Add a small test per command asserting the emitted JSON has the expected shape (e.g. for status: array of job records; for result: single record; for setup:
{checks, allOk, ...}).
| 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))); |
There was a problem hiding this comment.
[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.
| // 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; |
There was a problem hiding this comment.
[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.
Summary
Second of four PRs porting the high-value patterns from
openai/codex-plugin-cc(see analysis in the session that produced #17). This one adds session tracking:hooks/hooks.json+scripts/session-hook.mjs— SessionStart exports the Claude session id (andCLAUDE_PLUGIN_DATA) intoCLAUDE_ENV_FILE; SessionEnd cancels the session's still-running jobs. A closed Claude session no longer leaves detached workers +cursor-agentrunning unattended. Jobs from other sessions, or without a session stamp, are never touched — intentionally long-running jobs can still be started outside the hook's reach.sessionId, stamped centrally increateJob)./cursor:statusdefault view is session-scoped (own + unattributed jobs, 10-row cap, hint line when rows are hidden);--alllifts both.--jsonon/cursor:status//cursor:result//cursor:setup— raw job records / structured doctor report, for scripting and for the upcoming stop-review-gate hook (phase 3).CLAUDE_PLUGIN_DATAfor fresh installs (…/state/jobs/<repo-hash>/, cleaned up with the plugin). An existing~/.cursor-plugin-ccalways wins, so no install ever loses job history;CURSOR_PLUGIN_CC_HOMEstill overrides everything. Layout under the root is unchanged.Notes
hooks/hooks.jsonis a new top-level plugin dir (same layout as codex-plugin-cc);session-hook.mjsis the one non-command script underscripts/— both documented in AGENTS.md.tests/helpers.mjsoverlaps, with identical content — merges clean either order).Test plan
tests/session.test.mjs: session stamping from env,filterJobsForSession, SessionStart env-file write (incl. quote escaping), SessionEnd cancels only the session's running jobs, no-op paths.tests/paths.test.mjs: state-root precedence (env override / legacy dir wins / plugin-data fresh install).npm test: 99/99 green.npm run lint: clean.