Skip to content

fix: complete hook harness sessions at session end and sweep stale bindings - #79

Open
LorranHippolyte wants to merge 4 commits into
vinilana:mainfrom
LorranHippolyte:fix/complete-hook-harness-sessions
Open

fix: complete hook harness sessions at session end and sweep stale bindings#79
LorranHippolyte wants to merge 4 commits into
vinilana:mainfrom
LorranHippolyte:fix/complete-hook-harness-sessions

Conversation

@LorranHippolyte

Copy link
Copy Markdown
Contributor

Builds on #78 (the first commit here is that branch; review the last commit for this change's diff).

Problem

SessionStart creates 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:

  • The Stop dispatch only calls workflow-guide. In Claude Code, Stop fires at the end of every response turn, not at session end, so it is also the wrong place to complete the session.
  • completeSession exists in the harness action service but is never reached from the hook flow.
  • Bindings in host-sessions.json are written and read, never removed.

Result: every host session leaves behind a forever-active session 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. SessionEnd support (the correct lifecycle event).

  • The Claude Code hook template now wires SessionEnd alongside SessionStart/PostToolUse/Stop.
  • Dispatch canonicalizes SessionEnd/session_end and, on that event, completes the bound harness session (harness completeSession, traced as session.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.
  • The existing session-end reentry guard also covers SessionEnd.

2. Stale-binding sweep at SessionStart (covers sessions that never got a SessionEnd).

  • After binding the current session, dispatch sweeps bindings of the same source not touched for 24h (crash, closed terminal, host without a session-end event — Codex hooks today only wire SessionStart/PostToolUse/Stop), completing their harness sessions and removing the bindings.
  • The current host session is always skipped, and ensureHookHarnessSession now touches updatedAt on reuse so a resumed session is never treated as stale.

Stop behavior is unchanged (workflow guidance only).

Store additions (hookSessionStore.ts)

removeHookHarnessSession, listHookHarnessSessionBindings, completeHookHarnessSession, sweepStaleHookHarnessSessions, STALE_HOOK_SESSION_MAX_AGE_MS; HookSessionAdapter params widened to a createSession | completeSession union.

Tests

  • Store: binding removal (including missing entries), completion marks session.json as completed and unbinds, no-binding returns false without throwing, sweep completes only stale non-current bindings, updatedAt touch on reuse.
  • Dispatch integration: SessionStart → SessionEnd completes and unbinds; unbound SessionEnd stays non-blocking; session_end_active reentry does not complete; a stale binding from a previous host session is swept by the next SessionStart.
  • Docs updated (using-with-hooks en/pt-br, hook-session-flow pt-br) — including correcting Stop from "fim da sessão" to "fim da resposta".
  • npm run build and npm test -- --runInBand pass (the pre-existing src/__tests__/cli.test.ts failures on Node 26 are unrelated and fail identically on main).

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SessionEnd handling 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 dispatch when available and otherwise use npx -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.

Comment on lines +64 to +69
for (const directory of directories) {
for (const name of names) {
try {
if (fs.statSync(path.join(directory, name)).isFile()) {
return true;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a real thing, we need to fix it before merging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The executability check landed on the base branch (PR #78, d576133) and this branch is now rebased on top of it: isDotcontextBinaryOnPath() requires X_OK on non-win32 before selecting the local-binary form, with test coverage.

Comment on lines +258 to +260
const updatedAt = Date.parse(binding.updatedAt);
if (Number.isNaN(updatedAt) || updatedAt > cutoff) {
continue;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 vinilana left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one additional session-lifecycle issue in the last commit. I did not duplicate the two existing unresolved findings.

}

try {
await removeHookHarnessSession(options);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

LorranHippolyte and others added 3 commits July 10, 2026 11:25
- 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>
@LorranHippolyte
LorranHippolyte force-pushed the fix/complete-hook-harness-sessions branch from 80c4190 to ec2b391 Compare July 10, 2026 14:33
@LorranHippolyte

Copy link
Copy Markdown
Contributor Author

Rebased onto the updated fix/hook-dispatch-local-binary (PR #78, which now carries the X_OK executability fix) and pushed ec2b391 addressing the remaining findings:

  • P1 retry path: the binding is kept when completeSession fails transiently and removed only on success or when the harness session is confirmed missing, so the stale sweep can always retry instead of leaking an active session.
  • Sweep vs long-lived sessions: PostToolUse now touches the binding's updatedAt, so sessions that keep emitting tool events are never treated as stale.

Full suite passing locally (npm run build && npm test -- --runInBand; the only failures are the pre-existing cli.test.ts ones caused by an unquoted repo path with spaces in execSync, unrelated to this change). Docs updated accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants