Skip to content

Make the Stop hook async and UserPromptSubmit single-process (v3.0.3)#7

Merged
koenvdheide merged 5 commits into
mainfrom
perf/async-stop-single-process-ups
Jun 27, 2026
Merged

Make the Stop hook async and UserPromptSubmit single-process (v3.0.3)#7
koenvdheide merged 5 commits into
mainfrom
perf/async-stop-single-process-ups

Conversation

@koenvdheide

Copy link
Copy Markdown
Owner

What

Cuts the per-message latency the two hooks add, on Windows especially, where process spawning dominates.

  • The Stop hook runs asynchronously, so the handoff write no longer blocks the end of a turn.
  • The UserPromptSubmit hook merges its stdin parse and transcript tail-scan into one Python process (was two), removing an interpreter cold-start per message.

Why

On a Windows laptop the hooks added noticeable lag to every message: each invocation is a bash plus Python cold-start, and the UserPromptSubmit hook spawned Python twice per turn. The context-size check alone measured around 1.2s per message in session telemetry.

Two fixes folded in (caught in review)

  • Path resolution converts the transcript path with cygpath first on Git Bash, matching the pre-merge behaviour. A direct existence check could otherwise resolve a POSIX path like /tmp/x to a drive-relative C:\tmp\x and scan the wrong file.
  • With the Stop hook now async, two runs for one session can overlap. os.replace prevents partial reads but not writer ordering, so the hook compares the on-disk handoff's transcript_mtime_at_write before replacing and skips the write when the existing file is newer.

Tests

Full suite passes: 131/131. Adds T-38g, a deterministic test for the mtime guard. The cygpath-first change is covered by the existing /tmp-path tests.

Notes

Patch release (3.0.3). This does not include the larger flag-file redesign planned for 3.1.0, which moves the token scan into the Stop hook so the UserPromptSubmit hook becomes a pure-bash flag reader.

koenvdheide and others added 2 commits June 26, 2026 16:42
Stop runs async so turn-end no longer blocks on the handoff write. UserPromptSubmit merges its two Python spawns into one, dropping an interpreter cold-start per message (the dominant per-message cost on Windows). Path resolution stays cygpath-first to avoid a POSIX/drive-relative collision; the async Stop guards the handoff replace by transcript mtime so a slow run cannot overwrite a newer one. Adds T-38g for the guard; suite 131/131.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1be683e2a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hooks/hooks.json
{
"hooks": [
{ "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"" }
{ "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"", "async": true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't mark stale handoffs current from async Stop

The official Claude Code hook docs state that async hooks start in the background and Claude continues without waiting, so with this flag a user can submit the next prompt before update-handoff.sh has replaced handoff-<sid>.json. check-context-size.sh only checks that the handoff file exists before telling the model it “is current”, so in that window /prep-compact can read the previous turn's files/todos/requests while the reminder claims it is fresh. Either keep this hook synchronous or add a freshness/in-progress check before advertising the handoff as current.

Useful? React with 👍 / 👎.

Comment thread test/run-tests.sh
# After T7: EXPECTED_PASS=88, SKIPPED=39 (T7 test not Stop-dep)
# PR-comment fix: +3 Stop-dep (T-32cap +1 short-still-captured, T-32prior +2)
EXPECTED_PASS=130
EXPECTED_PASS=131

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update skipped Stop-hook count with new assertion

When test/fixtures/stop-real.json is absent—the local-dev path this harness explicitly supports—the new T-38g assertion is skipped with the rest of the Stop-hook block, but SKIPPED remains 59 while EXPECTED_PASS was bumped to 131. That path now fails the false-green guard with PASS=71 + SKIPPED=59, so local developers without the captured fixture can no longer run the non-Stop tests; bump the skipped count with the expected count.

Useful? React with 👍 / 👎.

@koenvdheide

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b27e9d95ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hooks/update-handoff.sh
Comment on lines +464 to +466
if isinstance(_emt, (int, float)) and _emt > transcript_mtime:
os.unlink(tmp_path)
sys.exit(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Merge stale-run context before skipping

When two async Stop hooks overlap and the later run has already written a handoff for a newer transcript, this branch deletes the older run's temp file and exits. If the rapid subsequent turn pushed a user request or file reference out of the 1 MB transcript tail, the newer run could not capture that data from the prior handoff yet, and this skip drops the only run that did capture it; /prep-compact can then lose recent intent or touched files. Consider merging the stale run's additive fields into the existing newer handoff instead of dropping the temp wholesale.

Useful? React with 👍 / 👎.

Comment thread hooks/hooks.json
{
"hooks": [
{ "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"" }
{ "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"", "async": true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Defer the warning until the async handoff is ready

With the Stop hook now backgrounded, a user who submits the next prompt immediately after crossing the token threshold can hit UserPromptSubmit before this async write has created or refreshed handoff-<sid>.json. In that case check-context-size.sh emits the no-handoff reminder and creates compact-warned-<sid>, so the later successful handoff write will not trigger a corrected handoff-aware reminder during the same crossing. Previously the synchronous Stop hook made the handoff available before the next prompt hook ran.

Useful? React with 👍 / 👎.

Comment thread hooks/update-handoff.sh
Comment on lines +460 to +463
if os.path.exists(handoff_path):
with open(handoff_path, 'r', encoding='utf-8') as f:
_existing = json.load(f)
_emt = _existing.get('transcript_mtime_at_write')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make freshness check atomic with replace

In an async overlap, this read-before-replace guard can still let an older Stop run clobber a newer handoff: run A can pass this check while the file is absent or older, then run B writes a fresher handoff, and finally run A resumes at os.replace and overwrites B. That interleaving leaves the stale handoff on disk even though the mtime guard was intended to prevent stale writers, so the check needs to be coupled with the replace via locking or another atomic ordering mechanism.

Useful? React with 👍 / 👎.

@koenvdheide koenvdheide merged commit f9a9285 into main Jun 27, 2026
2 checks passed
@koenvdheide koenvdheide deleted the perf/async-stop-single-process-ups branch June 27, 2026 11:58
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.

1 participant