Skip to content

fix(cli): suppress premature stop-hook notifications during auto-continue#2146

Open
anthhub wants to merge 1 commit intomanaflow-ai:mainfrom
anthhub:fix/notification-per-turn-dedup
Open

fix(cli): suppress premature stop-hook notifications during auto-continue#2146
anthhub wants to merge 1 commit intomanaflow-ai:mainfrom
anthhub:fix/notification-per-turn-dedup

Conversation

@anthhub
Copy link
Contributor

@anthhub anthhub commented Mar 25, 2026

Problem

Closes #2077

The claude-hook stop handler fires on every Claude Code turn, not just when the task is fully complete. This caused spurious desktop notifications mid-task: each time Claude finished a response and paused (even briefly between tool calls), the user received a "Completed" notification.

Root cause

CLI/cmux.swift stop handler calls notify_target unconditionally whenever stop fires. Claude Code's stop hook is a per-turn lifecycle event; there is no built-in way to distinguish "Claude paused between tool calls (auto-continue)" from "Claude finished the task and is waiting for the human."

Fix: timestamp-based deduplication

Track the last pre-tool-use event time in the session record. In the stop handler, skip the notification if a tool was used within the past 2 seconds:

  • Auto-continue turns: stop fires immediately after Claude finishes a response, then pre-tool-use fires within ~500 ms as Claude resumes. When the next stop arrives, lastToolUseAt is fresh — notification is suppressed.
  • Genuine completions: Claude produces a final response with no follow-up tool call. The 2-second window expires before any pre-tool-use updates the timestamp, so the notification fires normally.
  • First turn / no tool history: lastToolUseAt is nil → notification fires as before (no regression).

Changes

File Change
CLI/cmux.swift Add lastToolUseAt: TimeInterval? field to ClaudeHookSessionRecord
CLI/cmux.swift Add lastToolUseAt parameter to ClaudeHookSessionStore.upsert()
CLI/cmux.swift In pre-tool-use handler: record Date().timeIntervalSince1970 on every tool call
CLI/cmux.swift In stop handler: guard notify_target behind 2-second staleness check

The notification hook path, OSC 777, and Codex notification paths are not affected.

Test plan

  • Start Claude Code with a multi-step task (file edits + shell commands). Verify no notification during intermediate turns.
  • After the task finishes and Claude stops calling tools, verify the notification does fire (> 2 s elapsed).
  • Single-turn question-answer (no tool calls, lastToolUseAt is nil). Verify notification fires normally.
  • Trigger the notification hook (e.g. Claude uses AskUserQuestion). Verify notification is unaffected by this change.
  • Rapid stop/restart within 2 s (e.g. user sends immediate follow-up). No spurious notification expected.

🤖 Generated with Claude Code


Summary by cubic

Suppresses premature "Completed" notifications during auto-continue by gating the stop-hook. Users now only get a completion alert when the task actually finishes.

  • Bug Fixes
    • Track lastToolUseAt on each pre-tool-use event and store it in the session.
    • In the stop handler, send notify_target only if the last tool use was > 2 seconds ago or absent.
    • No changes to the notification hook, OSC 777, or Codex notification paths.

Written for commit fc64059. Summary will update on new commits.

Summary by CodeRabbit

  • Improvements
    • Enhanced the completion notification system to better manage notification timing during active tool usage sessions.
    • Implemented session-level tracking of recent tool interactions to prevent premature completion notifications from interrupting user workflows.
    • Notifications are now suppressed when consecutive tool operations occur within a short time window, reducing notification noise during extended active sessions.

…inue turns

Track the timestamp of each pre-tool-use event in ClaudeHookSessionRecord.
In the stop handler, skip notify_target if the last tool use occurred within
the past 2 seconds — indicating Claude is mid-task (auto-continue) rather
than truly finished. Genuine completions always have > 2 s of user think-time
before the next turn, so the threshold is safe for normal workflows.

Fixes manaflow-ai#2077
@vercel
Copy link

vercel bot commented Mar 25, 2026

@anthhub is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

Extended session tracking to record the timestamp of the last tool use, then implemented a heuristic in the stop hook to suppress completion notifications if fired within 2 seconds of tool use, preventing premature alerts during active Claude Code loops.

Changes

Cohort / File(s) Summary
Session tracking and notification suppression
CLI/cmux.swift
Added lastToolUseAt: TimeInterval? field to ClaudeHookSessionRecord to track pre-tool-use timestamps. Updated ClaudeHookSessionStore.upsert(...) to accept and persist this field. Modified the stop/idle notification path to suppress notifications when elapsed time since last tool use is ≤ 2.0 seconds. Added logic in the pre-tool-use hook handler to record lastToolUseAt before status/notification work.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A whisker-twitch of timing wise,
We track when Claude's tool-use flies,
Two seconds pass—no bell shall ring,
Let agents work on their own thing!
Notifications pause and wait,
'Til Claude's true task reaches fate. 🔔✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: suppressing premature notifications during auto-continue, which is the core objective of this PR.
Description check ✅ Passed The description covers the problem, root cause, and fix with implementation details. A test plan is provided, though some test items are incomplete.
Linked Issues check ✅ Passed The implementation directly addresses issue #2077 by implementing the timestamp-based deduplication approach to distinguish auto-continue from genuine task completion.
Out of Scope Changes check ✅ Passed All changes are scoped to the stated objectives: adding lastToolUseAt tracking, implementing the 2-second staleness check, and leaving other notification paths unchanged.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 1 file

@greptile-apps
Copy link

greptile-apps bot commented Mar 25, 2026

Greptile Summary

This PR fixes spurious "Completed" desktop notifications that fired on every Claude Code turn (including mid-task auto-continue pauses) by introducing a timestamp-based heuristic: the stop handler suppresses notify_target if a pre-tool-use event was recorded within the past 2 seconds, inferring that Claude is still mid-task rather than genuinely finished.

Key changes in CLI/cmux.swift:

  • Adds lastToolUseAt: TimeInterval? to ClaudeHookSessionRecord and the upsert() signature.
  • pre-tool-use handler records Date().timeIntervalSince1970 on every tool call (for the existing session only).
  • stop handler gates notify_target behind Date().timeIntervalSince1970 - lastToolUseAt > 2.0; falls back to always-notify when lastToolUseAt is nil.
  • The notification hook, OSC 777, and Codex notification paths are untouched.

Two edge cases to be aware of:

  • Fast final-turn completions: if the last pre-tool-use and the subsequent stop are separated by < 2 s (fast tool + short LLM reply), the genuine completion notification is silently dropped.
  • Stale lastToolUseAt across turns: lastToolUseAt is never cleared on prompt-submit, so a quick no-tool follow-up turn within 2 s of the previous turn's last tool use can also be silently suppressed.

Confidence Score: 4/5

  • Safe to merge; the fix addresses a real and frequent UX problem with a clean, minimal change — two edge cases exist but are unlikely to affect most users.
  • The implementation is correct for the common case and follows existing patterns. The two flagged edge cases (fast final-turn notification miss, stale timestamp across turns) are real but low-frequency in typical usage, given that LLM inference + tool execution usually exceeds 2 s and users rarely send immediate no-tool follow-ups. No data loss, security, or crash risk.
  • CLI/cmux.swift — specifically the 2-second staleness check in the stop handler and the missing lastToolUseAt reset in the prompt-submit case.

Important Files Changed

Filename Overview
CLI/cmux.swift Adds lastToolUseAt: TimeInterval? to ClaudeHookSessionRecord, records it in the pre-tool-use handler, and guards notify_target in the stop handler behind a 2-second staleness check. Logic is clean and fits the existing upsert pattern. Two edge cases worth watching: (1) a fast final tool+response sequence where stop fires in < 2 s can miss the notification, and (2) lastToolUseAt is never cleared on prompt-submit, so a quick no-tool follow-up turn within 2 s of the prior turn's last tool use is also silently suppressed.

Sequence Diagram

sequenceDiagram
    participant CC as Claude Code
    participant H as cmux Hook CLI
    participant S as SessionStore
    participant N as notify_target

    rect rgb(200, 230, 200)
        note over CC,N: Auto-continue turn (notification suppressed)
        CC->>H: pre-tool-use (turn N)
        H->>S: upsert(lastToolUseAt = T)
        CC->>H: stop (turn N, at T+0.4s)
        H->>S: lookup → lastToolUseAt = T
        note over H: now - T = 0.4s < 2.0s → skip
        CC->>H: pre-tool-use (turn N+1, at T+0.9s)
        H->>S: upsert(lastToolUseAt = T+0.9)
    end

    rect rgb(200, 210, 255)
        note over CC,N: Genuine completion (notification fires)
        CC->>H: stop (final, at T+0.9+3s)
        H->>S: lookup → lastToolUseAt = T+0.9
        note over H: now - (T+0.9) = 3s > 2.0s → notify
        H->>N: notify_target workspaceId surfaceId payload
    end

    rect rgb(255, 230, 200)
        note over CC,N: Edge case — fast final tool + short reply
        CC->>H: pre-tool-use (last tool, at T2)
        H->>S: upsert(lastToolUseAt = T2)
        CC->>H: stop (at T2+0.8s, short reply)
        H->>S: lookup → lastToolUseAt = T2
        note over H: now - T2 = 0.8s < 2.0s → skip ⚠️ missed!
    end
Loading

Reviews (1): Last reviewed commit: "fix(cli): suppress premature stop-hook n..." | Re-trigger Greptile

Comment on lines 10337 to +10355
if let completion {
let title = "Claude Code"
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
// Suppress notification if a tool was used very recently — this
// indicates an auto-continue turn (Claude stopped briefly between
// tool calls) rather than true task completion. The threshold of
// 2 seconds is generous: auto-continue stop→pre-tool-use gaps are
// typically < 500 ms, while genuine completions require user think-time.
let shouldNotify: Bool
if let lastToolUse = mappedSession?.lastToolUseAt {
shouldNotify = Date().timeIntervalSince1970 - lastToolUse > 2.0
} else {
shouldNotify = true
}
if shouldNotify {
let title = "Claude Code"
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
}
Copy link

Choose a reason for hiding this comment

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

P2 2-second window can suppress genuine completion notifications

The heuristic correctly handles the common case, but the 2-second threshold measures the gap between pre-tool-use and stop — which includes both tool execution time AND LLM inference time for the same turn.

For fast tool calls (e.g. read_file completing in ~50 ms) followed by a short Claude response (LLM inference ~0.5–1 s for a brief reply), stop can fire well within 2 seconds of the last pre-tool-use. In that scenario the final-turn notification is silently suppressed even though the task is genuinely complete.

Concretely: pre-tool-use at T → read_file runs in 0.05 s → Claude generates a 30-word reply in 0.7 s → stop at T + 0.75 s → 0.75 < 2.0 → notification dropped.

The PR description notes "genuine completions require user think-time", which holds when Claude produces a long response, but not for short, fast final replies. Consider documenting this known limitation and/or providing a mechanism to tune the threshold via a user default (similar to claudeCodeVerboseStatus), so users with consistently fast pipelines can lower it.

Comment on lines +10491 to +10499
if let sessionId = parsedInput.sessionId, let existingSession = mappedSession {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: existingSession.surfaceId,
cwd: parsedInput.cwd,
lastToolUseAt: Date().timeIntervalSince1970
)
}
Copy link

Choose a reason for hiding this comment

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

P2 lastToolUseAt not reset on prompt-submit

lastToolUseAt persists across consecutive turns within the same Claude Code session (sessions share a sessionId). If Turn N's last pre-tool-use fired at time T, and the user immediately sends a new prompt (Turn N+1), and Claude responds without any tool calls, stop for Turn N+1 fires with the stale lastToolUseAt = T. If the elapsed time is still < 2 s, the notification is suppressed.

Practical impact: A user who quickly follows up after a tool-heavy turn and gets a fast tool-free reply could silently miss the completion notification.

Adding a lastToolUseAt = nil reset inside the prompt-submit case would close this gap:

// prompt-submit case — after clearing notifications
if let sessionId = parsedInput.sessionId, let existingSession = mappedSession {
    try? sessionStore.upsert(
        sessionId: sessionId,
        workspaceId: workspaceId,
        surfaceId: existingSession.surfaceId,
        cwd: parsedInput.cwd
        // lastToolUseAt not passed → remains nil after explicit reset
    )
}

This would require adding an explicit "clear" path (e.g. a resetLastToolUseAt: Bool = false flag to upsert) rather than the current nil-means-no-change pattern.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 10338-10349: The cooldown logic wrongly treats
mappedSession?.lastToolUseAt as a session-wide timestamp, so a prior tool use in
any earlier turn can suppress a later legitimate completion; change the logic to
be turn-local by either resetting lastToolUseAt at turn start or, better,
introduce and use a per-turn flag (e.g., toolUsedThisTurn or a turn-scoped
lastToolUseAt on the current turn object) when computing shouldNotify instead of
the session-wide mappedSession?.lastToolUseAt; update the code paths that set
lastToolUseAt (the stop/tool handlers around lines that set lastToolUseAt) to
set the per-turn marker and modify the shouldNotify computation (the block that
computes shouldNotify using Date().timeIntervalSince1970 and lastToolUseAt) to
consult that turn-local marker so only tool use in the same turn suppresses
notification.
- Around line 10350-10354: Replace the hard-coded title "Claude Code" with a
localized string using String(localized: ..., defaultValue: ...); update the
code where title is defined (the title variable near sanitizeNotificationField
and sendV1Command) to call String(localized: "notification.claudeCode.title",
defaultValue: "Claude Code") (or an appropriate key) so the payload passed to
sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client:
client) contains the localized title while leaving sanitizeNotificationField,
workspaceId, surfaceId and sendV1Command calls unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 17aa747c-e08f-414e-b141-3d814b13fa21

📥 Commits

Reviewing files that changed from the base of the PR and between 99ca3c9 and fc64059.

📒 Files selected for processing (1)
  • CLI/cmux.swift

Comment on lines +10338 to +10349
// Suppress notification if a tool was used very recently — this
// indicates an auto-continue turn (Claude stopped briefly between
// tool calls) rather than true task completion. The threshold of
// 2 seconds is generous: auto-continue stop→pre-tool-use gaps are
// typically < 500 ms, while genuine completions require user think-time.
let shouldNotify: Bool
if let lastToolUse = mappedSession?.lastToolUseAt {
shouldNotify = Date().timeIntervalSince1970 - lastToolUse > 2.0
} else {
shouldNotify = true
}
if shouldNotify {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This is a session-wide cooldown, not a turn-local signal.

Line 10491 only sets lastToolUseAt, and Line 10344 then treats it as a 2-second global cooldown. That means a later stop in the same Claude session can suppress a real completion even when the current turn never used a tool, e.g. a quick no-tool follow-up right after prompt-submit.

Also applies to: 10488-10499

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 10338 - 10349, The cooldown logic wrongly treats
mappedSession?.lastToolUseAt as a session-wide timestamp, so a prior tool use in
any earlier turn can suppress a later legitimate completion; change the logic to
be turn-local by either resetting lastToolUseAt at turn start or, better,
introduce and use a per-turn flag (e.g., toolUsedThisTurn or a turn-scoped
lastToolUseAt on the current turn object) when computing shouldNotify instead of
the session-wide mappedSession?.lastToolUseAt; update the code paths that set
lastToolUseAt (the stop/tool handlers around lines that set lastToolUseAt) to
set the per-turn marker and modify the shouldNotify computation (the block that
computes shouldNotify using Date().timeIntervalSince1970 and lastToolUseAt) to
consult that turn-local marker so only tool use in the same turn suppresses
notification.

Comment on lines +10350 to +10354
let title = "Claude Code"
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Localize the new completion notification title.

This "Claude Code" literal is sent through notify_target and shown in the app notification UI, so it shouldn't introduce another hard-coded English string here.

As per coding guidelines, **/*.swift: All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") for every string shown in the UI.`

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 10350 - 10354, Replace the hard-coded title
"Claude Code" with a localized string using String(localized: ..., defaultValue:
...); update the code where title is defined (the title variable near
sanitizeNotificationField and sendV1Command) to call String(localized:
"notification.claudeCode.title", defaultValue: "Claude Code") (or an appropriate
key) so the payload passed to sendV1Command("notify_target \(workspaceId)
\(surfaceId) \(payload)", client: client) contains the localized title while
leaving sanitizeNotificationField, workspaceId, surfaceId and sendV1Command
calls unchanged.

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.

Notifications fire prematurely on every Claude Code turn, not just task completion

1 participant