-
Notifications
You must be signed in to change notification settings - Fork 8
feat(session): session lifecycle hooks, CLAUDE_PLUGIN_DATA state root, --json output #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } |
| 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); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
|
||
| const mine = listJobs(root).filter((j) => j.status === 'running' && j.sessionId === sessionId); | ||
| await Promise.all(mine.map((j) => cancelJob(root, j.id, 3_000))); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MINOR]
|
||
| 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); | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[SUGGESTION]
pluginHome()callsexistsSync(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.