Summary
The plugin's SessionEnd hook (ctx journal import --all -y) is cancelled by Claude Code on every exit, so the journal is never imported. Two causes stack:
- Claude Code gives
SessionEnd hooks a shared 1.5 s budget, and a timeout set on a plugin-provided hook does not raise it.
ctx journal import --all (and ctx journal source) fully parse every transcript under ~/.claude/projects, for all projects, before applying the cwd filter, then spawn one git remote get-url per parsed session. On a machine with a few hundred MB of transcripts this takes tens of seconds, so it can never fit the budget.
Since Claude Code 2.1.271 the cancellation is shown to the user on exit:
SessionEnd hook [command -v ctx >/dev/null 2>&1 || exit 0; ... cd "$CLAUDE_PROJECT_DIR" && ctx journal import --all -y >/dev/null 2>&1 || true] failed: Hook cancelled
Before that it was cancelled silently, which is probably why nobody reported it.
Environment
- Claude Code 2.1.284, Windows 11, Git Bash as the hook shell
- ctx dev build from a fork of
main; every code reference below was checked against upstream main @ c6628bb and the lines are identical
~/.claude/projects: 1661 .jsonl transcripts, 3.0 GB, 101 project directories
- Current project's own transcripts: 8 files, 46 MB
Cause 1: SessionEnd budget
From the Claude Code hooks reference (https://code.claude.com/docs/en/hooks#sessionend):
SessionEnd hooks have a default timeout of 1.5 seconds. [...] Per-hook timeout: [...] The overall budget rises automatically to match the highest per-hook timeout in your settings files, up to 60 seconds. [...] Timeouts set on plugin-provided hooks don't raise the budget.
The hook at internal/assets/claude/hooks/hooks.json#L142 sets neither timeout nor async. Even if it set timeout, the plugin rule above makes it a no-op. The only user-side override is CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS, which blocks exit for that long.
Changelog trail: the 1.5 s kill exists since at least 2.1.74, 2.1.268 fixed the env override for hooks without their own timeout, 2.1.271 started showing the hook and its cancellation in the UI.
Cause 2: project-scoped scan parses the whole machine
findSessionsWithFilter scans ~/.claude/projects (plus Copilot and Codex dirs) via ScanDirectory, which calls p.ParseFile on every transcript. The cwd filter is applied only after everything is parsed.
FindSessionsForCWD then calls gitRemote(s.CWD) for every parsed session. gitRemote has no memoisation, so it is one git subprocess per session even when hundreds of sessions share a cwd. Process spawn is expensive on Windows.
Measurements on the machine above, warm cache (second run identical, so this is CPU and subprocess cost, not disk):
| Command |
Sessions |
Wall time |
ctx journal source --all-projects (parse only, no filter) |
420 |
25.6 s |
ctx journal source (cwd filter + gitRemote per session) |
6 |
38.3 s |
ctx journal import --all --dry-run |
27 new parts |
48.7 s |
Only 46 MB of the 3 GB parsed belong to the current project. Parse throughput here is roughly 120 MB/s, so the 1.5 s budget is exceeded once a machine has on the order of 150 to 200 MB of transcripts, before any git calls or Markdown rendering.
Consequences
.context/journal/ never gets populated on exit. On this machine it did not exist until I ran the dry-run by hand.
check-journal nags every prompt with "You have 1661 new session(s) not yet imported". That count comes from CountNewerFiles over all of ~/.claude/projects, so it reports every project's transcripts, not the current project's.
ctx journal source takes 38 s for a 6-session project.
Proposed fix
Two independent, small changes:
- Detach the import in the hook. Change the
SessionEnd command to cd "$CLAUDE_PROJECT_DIR" && nohup ctx journal import --all -y >/dev/null 2>&1 &. The hook returns in milliseconds and is never cancelled. The docs recommend exactly this for work that must outlive the session ("start a fully detached process"). Checked that a nohup'd child survives the hook shell's exit in Git Bash on Windows. Note that plugin hooks.json is what hack/plugin-reload.sh mirrors into the cache, so this is the only file to change on the ctx side. Codex's hooks.json has the same command with "timeout": 3, which has the same problem for the same reason.
- Filter before parsing. Peek
cwd from the first transcript line (the Claude parser already takes it from the first message, parse.go first.CWD) and apply the cwd predicate before calling ParseFile; memoise gitRemote per cwd. All three matching rules in FindSessionsForCWD use only s.CWD, so behaviour is unchanged and project-scoped scans drop from tens of seconds to well under one. ctx journal source benefits as well.
I am happy to open a PR for either or both.
Summary
The plugin's
SessionEndhook (ctx journal import --all -y) is cancelled by Claude Code on every exit, so the journal is never imported. Two causes stack:SessionEndhooks a shared 1.5 s budget, and atimeoutset on a plugin-provided hook does not raise it.ctx journal import --all(andctx journal source) fully parse every transcript under~/.claude/projects, for all projects, before applying the cwd filter, then spawn onegit remote get-urlper parsed session. On a machine with a few hundred MB of transcripts this takes tens of seconds, so it can never fit the budget.Since Claude Code 2.1.271 the cancellation is shown to the user on exit:
Before that it was cancelled silently, which is probably why nobody reported it.
Environment
main; every code reference below was checked against upstreammain@ c6628bb and the lines are identical~/.claude/projects: 1661.jsonltranscripts, 3.0 GB, 101 project directoriesCause 1: SessionEnd budget
From the Claude Code hooks reference (https://code.claude.com/docs/en/hooks#sessionend):
The hook at
internal/assets/claude/hooks/hooks.json#L142sets neithertimeoutnorasync. Even if it settimeout, the plugin rule above makes it a no-op. The only user-side override isCLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS, which blocks exit for that long.Changelog trail: the 1.5 s kill exists since at least 2.1.74, 2.1.268 fixed the env override for hooks without their own
timeout, 2.1.271 started showing the hook and its cancellation in the UI.Cause 2: project-scoped scan parses the whole machine
findSessionsWithFilterscans~/.claude/projects(plus Copilot and Codex dirs) viaScanDirectory, which callsp.ParseFileon every transcript. The cwd filter is applied only after everything is parsed.FindSessionsForCWDthen callsgitRemote(s.CWD)for every parsed session.gitRemotehas no memoisation, so it is onegitsubprocess per session even when hundreds of sessions share a cwd. Process spawn is expensive on Windows.Measurements on the machine above, warm cache (second run identical, so this is CPU and subprocess cost, not disk):
ctx journal source --all-projects(parse only, no filter)ctx journal source(cwd filter +gitRemoteper session)ctx journal import --all --dry-runOnly 46 MB of the 3 GB parsed belong to the current project. Parse throughput here is roughly 120 MB/s, so the 1.5 s budget is exceeded once a machine has on the order of 150 to 200 MB of transcripts, before any git calls or Markdown rendering.
Consequences
.context/journal/never gets populated on exit. On this machine it did not exist until I ran the dry-run by hand.check-journalnags every prompt with "You have 1661 new session(s) not yet imported". That count comes fromCountNewerFilesover all of~/.claude/projects, so it reports every project's transcripts, not the current project's.ctx journal sourcetakes 38 s for a 6-session project.Proposed fix
Two independent, small changes:
SessionEndcommand tocd "$CLAUDE_PROJECT_DIR" && nohup ctx journal import --all -y >/dev/null 2>&1 &. The hook returns in milliseconds and is never cancelled. The docs recommend exactly this for work that must outlive the session ("start a fully detached process"). Checked that anohup'd child survives the hook shell's exit in Git Bash on Windows. Note that pluginhooks.jsonis whathack/plugin-reload.shmirrors into the cache, so this is the only file to change on the ctx side. Codex'shooks.jsonhas the same command with"timeout": 3, which has the same problem for the same reason.cwdfrom the first transcript line (the Claude parser already takes it from the first message,parse.gofirst.CWD) and apply the cwd predicate before callingParseFile; memoisegitRemoteper cwd. All three matching rules inFindSessionsForCWDuse onlys.CWD, so behaviour is unchanged and project-scoped scans drop from tens of seconds to well under one.ctx journal sourcebenefits as well.I am happy to open a PR for either or both.