fix: complete hook harness sessions at session end and sweep stale bindings - #79
Conversation
Hook configs previously persisted `npx -y @dotcontext/cli@latest hook dispatch`, which spawns npx and re-resolves the `latest` dist-tag on every SessionStart/PostToolUse/Stop event. On busy sessions this produces a constant stream of short-lived Node processes and registry lookups on every file edit and shell command. The installer now writes `dotcontext hook dispatch --source <host>` when a global dotcontext binary is found on PATH, and otherwise falls back to npx pinned to the installed CLI version. Both forms are treated as current so installs do not churn configs; the legacy `@latest` form is upgraded on re-install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves hook-driven session hygiene across the integrations boundary by (1) completing harness sessions at the correct host lifecycle event (SessionEnd) and (2) preventing unbounded growth of stale host→harness bindings by sweeping old bindings on SessionStart. It also improves hook dispatch command persistence by preferring a local dotcontext binary (with a version-pinned npx fallback), reducing per-hook overhead and config churn.
Changes:
- Add
SessionEndhandling for Claude Code hooks to complete the bound harness session and remove the host-session binding, while keeping dispatch non-blocking. - Add binding lifecycle utilities to the hook session store (remove/list/complete/sweep) and run a stale-binding sweep at
SessionStart. - Update hook dispatch command resolution to prefer
dotcontext hook dispatchwhen available and otherwise usenpx -y @dotcontext/cli@<installed version> ..., with corresponding test + docs updates.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/integrations/shared/index.ts | Re-exports new hook dispatch command helpers and hook session store lifecycle APIs. |
| src/integrations/shared/hookSessionStore.ts | Adds binding removal/listing, completion, stale sweep, and touches updatedAt on session reuse. |
| src/integrations/shared/hookDispatchCommands.ts | Introduces local-binary vs pinned-npx dispatch command resolution and canonical command matching. |
| src/integrations/shared/tests/hookSessionStore.test.ts | Adds coverage for binding touch/removal/completion/sweep behavior and version pin assertions. |
| src/integrations/shared/tests/hookDispatchCommands.test.ts | Adds tests for PATH binary detection, command resolution, and canonical acceptance / legacy rejection. |
| src/integrations/codex/hooks/index.ts | Re-exports the new Codex hook template builder. |
| src/integrations/codex/hooks/codexHookTemplates.ts | Builds Codex templates using resolved dispatch command (while retaining a static pinned-npx template). |
| src/integrations/claude-code/hooks/index.ts | Re-exports the new Claude Code hook template builder. |
| src/integrations/claude-code/hooks/claudeCodeHookTemplates.ts | Adds SessionEnd wiring and builds templates using resolved dispatch command (static pinned-npx template retained). |
| src/integrations/tests/hookInstallServices.test.ts | Updates install-service tests to assert resolved dispatch command and legacy upgrade behavior. |
| src/cli/services/hookDispatchService.ts | Canonicalizes SessionEnd, completes/unbinds on SessionEnd, and sweeps stale bindings on SessionStart. |
| src/cli/services/tests/hookInstallService.test.ts | Updates CLI install tests to assert resolved dispatch commands. |
| src/cli/services/tests/hookDispatchService.test.ts | Adds integration coverage for SessionStart → SessionEnd completion/unbind and stale sweep behavior. |
| README.md | Documents dotcontext hook dispatch preference with pinned-npx fallback. |
| docs/src/content/docs/pt-br/guides/using-with-hooks.md | Documents dispatch command forms and adds Claude Code SessionEnd + sweep behavior. |
| docs/src/content/docs/pt-br/guides/hook-session-flow.md | Updates dispatch command description and corrects Stop meaning; documents SessionEnd completion path. |
| docs/src/content/docs/en/guides/using-with-hooks.md | Documents dispatch command forms and adds Claude Code SessionEnd + sweep behavior. |
| docs/src/content/docs/en/about/architecture.md | Updates integrations boundary description to match new dispatch command behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (const directory of directories) { | ||
| for (const name of names) { | ||
| try { | ||
| if (fs.statSync(path.join(directory, name)).isFile()) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
This is a real thing, we need to fix it before merging.
| const updatedAt = Date.parse(binding.updatedAt); | ||
| if (Number.isNaN(updatedAt) || updatedAt > cutoff) { | ||
| continue; |
There was a problem hiding this comment.
Fixed in ec2b391. PostToolUse dispatch now touches the binding's updatedAt (best-effort, non-blocking), so long-lived host sessions that keep emitting tool events without re-triggering SessionStart are no longer swept as stale. Covered by a new dispatch-level test.
vinilana
left a comment
There was a problem hiding this comment.
I found one additional session-lifecycle issue in the last commit. I did not duplicate the two existing unresolved findings.
| } | ||
|
|
||
| try { | ||
| await removeHookHarnessSession(options); |
There was a problem hiding this comment.
[P1] Keep a retry path when completion fails. adapter.handle can return ok: false (or throw) for a transient runtime/policy/I/O failure, but this unconditionally deletes the only host→harness binding. The subsequent stale sweep then has no session ID to retry, leaving the original harness session active forever—the leak this change is intended to prevent. Retain a retryable binding (or a cleanup tombstone) until completion succeeds, except when the session is confirmed already terminal/not found.
There was a problem hiding this comment.
Fixed in ec2b391. completeHookHarnessSession now removes the binding only when completion succeeds or the harness session is confirmed missing ("Harness session not found"). On transient failures the binding is retained so the SessionStart stale sweep keeps a session id to retry — added tests covering the transient-failure retry path and the missing-session cleanup path.
- Require execute permission (X_OK) on POSIX before treating a PATH entry named dotcontext as an installed binary, so a stray non-executable file cannot cause the installer to persist a dispatch command that fails at runtime. - Treat the local-binary dispatch command as current only while the binary is actually on PATH; the pinned-npx form stays always current. Re-running the installer now repairs configs left behind by a global uninstall instead of skipping them as up to date. - Rename BuildCodexTomlHookBlocksOptions.command to resolveOptions so the property does not read as the command string itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndings SessionStart created a durable harness session and bound it in host-sessions.json, but nothing ever completed it: the Stop dispatch only calls workflow-guide (and in Claude Code, Stop fires per response turn, not at session end), and bindings were never removed. Every host session left a forever-active session under .context/runtime/sessions/ and a permanent binding entry. The Claude Code template now wires SessionEnd, and dispatch completes the bound harness session and removes its binding on that event — non-blocking, and the binding is removed even when completion fails. SessionStart additionally sweeps bindings untouched for 24h, covering host sessions that ended without SessionEnd (crash, closed terminal, hosts without a session-end event). Binding reuse now touches updatedAt so resumed sessions are never treated as stale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Keep the host->harness binding when completeSession fails transiently, so the SessionStart stale sweep retains a session id to retry instead of leaking a forever-active harness session. The binding is removed only on successful completion or when the harness session is confirmed missing. - Touch the binding's updatedAt on PostToolUse so long-lived host sessions that keep emitting tool events (without re-triggering SessionStart) are not swept as stale and completed while in use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
80c4190 to
ec2b391
Compare
|
Rebased onto the updated
Full suite passing locally ( |
Problem
SessionStartcreates a durable harness session (harness createSession) and binds it to the host session in.context/runtime/hooks/host-sessions.json— but nothing ever completes it:Stopdispatch only callsworkflow-guide. In Claude Code,Stopfires at the end of every response turn, not at session end, so it is also the wrong place to complete the session.completeSessionexists in the harness action service but is never reached from the hook flow.host-sessions.jsonare written and read, never removed.Result: every host session leaves behind a forever-
activesession under.context/runtime/sessions/and a permanent binding entry. Long-lived repos accumulate unbounded "started but never finished" runtime state.Change
Two complementary mechanisms:
1.
SessionEndsupport (the correct lifecycle event).SessionEndalongsideSessionStart/PostToolUse/Stop.SessionEnd/session_endand, on that event, completes the bound harness session (harness completeSession, traced assession.completed) and removes the binding. Output stays{"continue": true}and the whole path is non-blocking — completion failure still removes the binding so stale entries cannot accumulate.SessionEnd.2. Stale-binding sweep at
SessionStart(covers sessions that never got aSessionEnd).SessionStart/PostToolUse/Stop), completing their harness sessions and removing the bindings.ensureHookHarnessSessionnow touchesupdatedAton reuse so a resumed session is never treated as stale.Stopbehavior is unchanged (workflow guidance only).Store additions (
hookSessionStore.ts)removeHookHarnessSession,listHookHarnessSessionBindings,completeHookHarnessSession,sweepStaleHookHarnessSessions,STALE_HOOK_SESSION_MAX_AGE_MS;HookSessionAdapterparams widened to acreateSession | completeSessionunion.Tests
session.jsonascompletedand unbinds, no-binding returns false without throwing, sweep completes only stale non-current bindings,updatedAttouch on reuse.SessionStart → SessionEndcompletes and unbinds; unboundSessionEndstays non-blocking;session_end_activereentry does not complete; a stale binding from a previous host session is swept by the nextSessionStart.using-with-hooksen/pt-br,hook-session-flowpt-br) — including correctingStopfrom "fim da sessão" to "fim da resposta".npm run buildandnpm test -- --runInBandpass (the pre-existingsrc/__tests__/cli.test.tsfailures on Node 26 are unrelated and fail identically onmain).🤖 Generated with Claude Code