# How to know when Claude Code is done

> Source: <https://dev.to/agentislandpro/how-to-know-when-claude-code-is-done-492>
> Published: 2026-09-09 02:23:04+00:00

You start a long task, switch to something else, and come back nine minutes later to find it finished eight minutes ago — or worse, that it stopped to ask you a question and has been sitting there since.

There is a built-in notification for this. It is off in most terminals, and it only fires when Claude Code believes you are away.

Settings below are from [Claude Code's own documentation](https://code.claude.com/docs/en/terminal-config), September 2026.

Claude Code fires a notification when it finishes a task or pauses for a permission prompt — with a condition that explains most of the "it never notifies me" reports: **it only fires when you appear to be away from the terminal.**

If you are sitting in the window watching it work, there is nothing to interrupt you about, so nothing happens. Testing it by staring at the terminal is therefore a test designed to fail.

The second condition is the terminal itself. A desktop notification is sent by default in **Ghostty, Kitty and iTerm2**, and nowhere else. In any other terminal — Warp, the VS Code integrated terminal, Apple Terminal, Alacritty — nothing arrives until you ask for the bell instead:

```
// ~/.claude/settings.json
{
  "preferredNotifChannel": "terminal_bell"
}
```

Three further things swallow the notification even once it is enabled, and each looks like the feature being broken:

| If | Then | 
|---|---|
| You use iTerm2 | Forwarding is not on by default. Settings → Profiles → Terminal, enable Notification Center alerts, then under Filter Alerts allow escape-sequence-generated ones. | 
| You run inside tmux | tmux swallows it. `set -g allow-passthrough on` in`~/.tmux.conf` , then`tmux source-file ~/.tmux.conf` . | 
| Nothing appears anywhere | Check the terminal application itself has notification permission in your OS settings — the notification is delivered by the terminal, not by Claude Code. | 

If you would rather have a sound you choose, a `Notification` hook runs alongside the built-in one rather than replacing it:

```
{
  "hooks": {
    "Notification": [
      { "hooks": [{ "type": "command",
                    "command": "afplay /System/Library/Sounds/Glass.aiff" }] }
    ]
  }
}
```

That is the whole of the built-in answer, and for a single session it is usually enough. The rest of this is about why it stops being enough, and it starts with a definition problem.

If you build anything on top of this — a notifier, a status line, a dashboard — the first thing you discover is that "is it done?" has no single answer. There are three states, they mean different things to you, and only one is urgent:

| State | What it means | Does it need you? | 
|---|---|---|
| **Running** | Working. Tools are being called. | No | 
| **Waiting** | Stopped mid-task on a permission prompt, a question, or a plan to approve. | **Yes, and it is blocked until you answer** | 
| **Idle** | The turn ended. It said its piece and is waiting for your next instruction. | Only when you want it to | 

The events map onto those states cleanly enough once you know which is which:

`UserPromptSubmit` and `PostToolUse` → running. Each is also proof of life, which matters later.`Notification` → waiting. The message says whether it is a permission request or a question.`Stop` → idle. `SubagentStop` → nothing, as far as the parent is concerned. A subagent finishing says nothing about whether the session that spawned it needs you.
**The distinction that matters most is Waiting versus Idle**, because they feel identical from the outside — in both cases the terminal has gone quiet — and they could not be more different. Idle means you can look whenever you like. Waiting means it stopped mid-task and *nothing will happen until you answer*, which is the case where a nine-minute delay is nine minutes wasted.

A notifier that treats "quiet" as one state will either nag you about every finished turn or let you sit on a blocked prompt.

Here is the failure I shipped, and it is the one worth stealing the fix for.

On 21 August a session sat showing *waiting for your input* for over an hour. The user had answered in the terminal within seconds. Nothing was wrong with the answer — the problem was that the hooks had been connected *after* that session started, so the event that would have cleared the waiting state was never sent. The state was correct when it was written and quietly became a lie afterwards.

This is not a niche case. Any of these produce the same shape:

`SessionEnd` ever arrives
An event-driven state machine only knows what it was told. It cannot distinguish "still waiting" from "was waiting, and the update went missing", because both look like silence. So it needs one rule that does not depend on receiving anything:

```
// After this long with no event at all, a non-idle status
// is no longer evidence of anything.
static let staleAfter: TimeInterval = 10 * 60

func isStale(now: Date) -> Bool {
    status != .idle && now.timeIntervalSince(lastEventAt) >= staleAfter
}
```

Ten minutes of total silence and we stop asserting the state. Note the `status != .idle` half: idle is the one state that *should* persist, because a session that ended its turn an hour ago is still, correctly, a session that ended its turn. Only the active claims expire.

The principle generalises past this codebase: **a status you cannot stand behind must not be presented as current**, and stale evidence should decay on its own rather than wait for a correction that may never come.

One last thing worth copying if you write your own. Status events and permission prompts want opposite designs, and using one design for both is how you end up with a notifier that makes your agent feel slow.

|  | Status events | Permission prompt | 
|---|---|---|
| Job | Report what happened | Answer a question, or decline to | 
| Design | Fire and forget | Blocking | 
| My timeout | `curl -m 0.3` | `curl -m 52` , inside a configured`"timeout": 55` | 
| On failure | Give up silently, exit 0 | Print `{}` — no opinion — and exit 0 (exit 2 would block) | 

A status hook must never make the CLI wait: nothing depends on its answer, so a third of a second is generous and anything longer is a tax on every single tool call.

A permission hook is the opposite — something *is* waiting on its answer — but it still has to return inside whatever `timeout` it was given. The default for a command hook is 600 seconds, which is much longer than you want a blocking hook to hold a tool call, so set it deliberately.

Both exit 0 on an ordinary failure, and deliberately so, because exit 2 would block the call. There is more on that in [why your Claude Code hook isn't running](https://agentislandapp.github.io/hooks.html).

*Originally published at [agentislandapp.github.io/done.html](https://agentislandapp.github.io/done.html). I write these while building AgentIsland, a macOS app that puts this state in the notch: a dot per session while things are running, and the actual permission prompt the moment one is waiting.*
