Problem
On session exit, the plugin's SessionEnd hook is reported as failed on every substantive session close:
SessionEnd hook [node "${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs"] failed: Hook cancelled
This is not just a display artifact — the cleanup genuinely does not run. The session-scoped dedup temp files (<tmpdir>/vercel-plugin-<sessionId>-*, the claim dir / seen-skills state described in the plugin's own dedup contract) are left behind for closed sessions:
$ ls /tmp | grep vercel-plugin
vercel-plugin-1ec5a71c-...-greenfield.txt # session closed at 22:10 — cleanup never ran
vercel-plugin-1ec5a71c-...-likely-skills.txt
Root cause
hooks/hooks.json declares the SessionEnd hook with no timeout field (current main, blob 3c6562c).
In Claude Code (verified in the 2.1.214 bundle), executeSessionEndHooks invokes the hook runner with a default timeoutMs of 1500 for SessionEnd specifically. Per hook, the budget is hook.timeout * 1000 if configured, else that 1500ms default. A hook that exceeds its budget is aborted (ABORT_ERR → "Hook cancelled").
session-end-cleanup.mjs itself is fast — standalone it exits 0 in ~50ms:
$ echo '{"session_id":"x"}' | node hooks/session-end-cleanup.mjs # 0.047s, exit 0
But at a real session close the 1.5s window is easily consumed before the hook finishes: session teardown (notably with "tui": "fullscreen") plus sibling SessionEnd hooks run in the same instant. anthropics/claude-code#70465 (open) documents this exact class — a sub-second exit 0 SessionEnd hook cancelled alongside busy siblings. In my setup, user-configured SessionEnd hooks that declare explicit timeout values (10–30s) run to completion every close; the plugin's hook — the only SessionEnd hook without a timeout — is the only one that gets cancelled.
There's a second cost of not declaring a timeout: Claude Code derives its SessionEnd force-exit grace from the max configured timeout across all SessionEnd hooks, clamped to [1.5s, 60s]. For users whose only SessionEnd hook is this plugin's, everything collapses to the 1.5s floor.
Suggested fix
Declare a timeout on the SessionEnd hook (same one-field-per-hook shape as the shell fix proposed in #79):
"SessionEnd": [
{
"hooks": [
{
"type": "command",
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs\""
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs\"",
+ "timeout": 15
}
]
}
]
This has no latency cost in the normal case — the hook still exits in milliseconds; the timeout only widens the abort window so teardown contention can't kill it. In the same environment, every SessionEnd hook that declares an explicit timeout completes on every close; only this one — the only one relying on the 1.5s default — gets cancelled.
timeout is a documented per-hook field in the hooks reference, and the plugin's own doctor already reasons about hook timeout risk, so this seems like the intended knob.
Environment
- Claude Code 2.1.214,
"tui": "fullscreen"
- vercel-plugin 0.44.0 (marketplace install)
- Linux x86_64 (Arch-based)
Related
Problem
On session exit, the plugin's SessionEnd hook is reported as failed on every substantive session close:
This is not just a display artifact — the cleanup genuinely does not run. The session-scoped dedup temp files (
<tmpdir>/vercel-plugin-<sessionId>-*, the claim dir / seen-skills state described in the plugin's own dedup contract) are left behind for closed sessions:Root cause
hooks/hooks.jsondeclares the SessionEnd hook with notimeoutfield (currentmain, blob3c6562c).In Claude Code (verified in the 2.1.214 bundle),
executeSessionEndHooksinvokes the hook runner with a defaulttimeoutMsof 1500 for SessionEnd specifically. Per hook, the budget ishook.timeout * 1000if configured, else that 1500ms default. A hook that exceeds its budget is aborted (ABORT_ERR→ "Hook cancelled").session-end-cleanup.mjsitself is fast — standalone it exits 0 in ~50ms:But at a real session close the 1.5s window is easily consumed before the hook finishes: session teardown (notably with
"tui": "fullscreen") plus sibling SessionEnd hooks run in the same instant. anthropics/claude-code#70465 (open) documents this exact class — a sub-secondexit 0SessionEnd hook cancelled alongside busy siblings. In my setup, user-configured SessionEnd hooks that declare explicittimeoutvalues (10–30s) run to completion every close; the plugin's hook — the only SessionEnd hook without atimeout— is the only one that gets cancelled.There's a second cost of not declaring a timeout: Claude Code derives its SessionEnd force-exit grace from the max configured
timeoutacross all SessionEnd hooks, clamped to [1.5s, 60s]. For users whose only SessionEnd hook is this plugin's, everything collapses to the 1.5s floor.Suggested fix
Declare a
timeouton the SessionEnd hook (same one-field-per-hook shape as theshellfix proposed in #79):"SessionEnd": [ { "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs\"", + "timeout": 15 } ] } ]This has no latency cost in the normal case — the hook still exits in milliseconds; the
timeoutonly widens the abort window so teardown contention can't kill it. In the same environment, every SessionEnd hook that declares an explicittimeoutcompletes on every close; only this one — the only one relying on the 1.5s default — gets cancelled.timeoutis a documented per-hook field in the hooks reference, and the plugin's owndoctoralready reasons about hook timeout risk, so this seems like the intended knob.Environment
"tui": "fullscreen"Related
hooks.json