CLAUDE LABJP
KEYS — v2.1.238 adds a keybindingFlavor setting. Set it to readline and Ctrl+W deletes back to the previous whitespace, just as in Bash. The classic default is unchangedPLUGINS — Plugin marketplaces can now define a headersHelper that mints HTTP headers, such as a short-lived token, on each catalog fetch. Installing shows the command and asks before running itRUNNER — self-hosted-runner gained defer-shutdown-max-min. On SIGTERM it keeps serving attached sessions, then parks whatever is left after that many minutes and exitsMEMORY — Unbounded memory growth in long interactive sessions is fixed. Subagent tool results are now released once they leave the recent display windowMCP — mcp list and mcp get now show disabled servers as Disabled instead of connecting to them for a health check, so a server you turned off no longer starts just to be listedPRICING — Claude Sonnet 5's introductory $2 per million input and $10 output ends August 31, with standard $3 and $15 pricing from September 1. Nine days to goKEYS — v2.1.238 adds a keybindingFlavor setting. Set it to readline and Ctrl+W deletes back to the previous whitespace, just as in Bash. The classic default is unchangedPLUGINS — Plugin marketplaces can now define a headersHelper that mints HTTP headers, such as a short-lived token, on each catalog fetch. Installing shows the command and asks before running itRUNNER — self-hosted-runner gained defer-shutdown-max-min. On SIGTERM it keeps serving attached sessions, then parks whatever is left after that many minutes and exitsMEMORY — Unbounded memory growth in long interactive sessions is fixed. Subagent tool results are now released once they leave the recent display windowMCP — mcp list and mcp get now show disabled servers as Disabled instead of connecting to them for a health check, so a server you turned off no longer starts just to be listedPRICING — Claude Sonnet 5's introductory $2 per million input and $10 output ends August 31, with standard $3 and $15 pricing from September 1. Nine days to go
Articles/Claude Code
Claude Code/2026-08-22Beginner

Handing a long job to another session — and the completion marker for when the notification never arrives

How to use notify_when_idle to hear when another Claude Code session finishes, and a small completion marker that keeps you from waiting forever when the notification is dropped.

Claude Code231Automation43BuildShell scriptingOperations17

When I add a new series to one of my wallpaper apps, I run a batch that turns a single source image into eight derived variants. Once the count adds up, it takes close to ten minutes.

During those ten minutes I tell myself I have moved on to something else. In practice I was going back to the terminal every three minutes, purely to see whether it had finished.

The same thing happened when I handed long work to Claude Code. One session runs the build, the other keeps going on design questions. The arrangement itself is comfortable, but with no way to learn about completion, the number of times I looked never actually went down.

notify_when_idle, added in v2.1.236, removes almost all of that looking. Notifications do get dropped, though. This article covers both halves: the setup, and the small piece that keeps you from waiting in silence when nothing arrives.

What notify_when_idle covers, and what it doesn't

notify_when_idle lets you ask another Claude Code session on the same machine to tell you once, the next time it goes idle. It rides along with cross-session message sending, and it targets macOS and Linux.

Three things about the design are worth appreciating:

  • Opt-in. It only fires when you ask for it. Nothing notifies you by default.
  • One-shot. You never end up in a state where a session keeps pinging you.
  • No polling. The waiting side does not need to keep asking.

The boundaries are equally clear. The notification tells you the session went idle — nothing about whether the work succeeded. A build that dies on a compile error also leaves the session idle.

There is a second gap. If the other session dies outright, no notification happens at all, because there is no longer anything left to go idle. The longer the job, the more likely you are to hit that path.

So notify_when_idle is a tool for shortening the wait, not for guaranteeing the result. Separating those two jobs makes it obvious what to add next.

Setting up the two-session arrangement

The steps are short.

  1. Check that claude --version reports v2.1.236 or newer.
  2. Open the session that will do the long work and start the build or batch.
  3. From the other session, send a cross-session message with notify_when_idle attached.
  4. When the notification lands, go collect the result.

The exact spelling of the option and the order of its arguments can shift between versions, so it is worth checking your local help output once. I check after an update and then never touch it again.

Even those four steps change how the wait feels. The trouble starts when step four never happens.

Three ways the notification goes missing

Before v2.1.238, messages that were dropped on certain paths still looked successful to the sender. Now the failure comes back to you.

PathWhat is happeningWhat the sender sees
Receiver refusesThe other session has crossSessionInbound: "refuse" setrefused
Rate limitedToo many messages in a short windowThe drop is reported
Inbox is fullThe other session has unread messages piled upThe drop is reported

Previously, "I sent it and nothing happened" was indistinguishable from "it went through and they are just busy." With this fix you at least know when the request failed to land.

But a request landing and the work finishing are still two different facts. If the other session dies partway through, the request landed and the notification still never comes. That is the gap the completion marker fills.

Write one marker file, and only one

The idea is plain: wrap the long job in a thin shell that writes its state into a single file. Whether or not a notification arrives, reading that file tells you where things stand.

#!/usr/bin/env bash
# run-marked.sh — wrap a long job and record start, heartbeat, and finish in one file
set -uo pipefail
MARK="${1:?pass the path to the marker file}"; shift
TMP="${MARK}.tmp.$$"
 
write_mark() {
  printf '{"state":"%s","pid":%d,"at":%d,"exit":%s}\n' \
    "$1" "$$" "$(date +%s)" "${2:-null}" > "$TMP"
  mv -f "$TMP" "$MARK"
}
 
write_mark running
 
# Heartbeat every 2 seconds; it stops when the parent goes away
( while kill -0 "$$" 2>/dev/null; do sleep 2; write_mark running; done ) &
HB=$!
 
"$@"; CODE=$?
 
kill "$HB" 2>/dev/null
write_mark done "$CODE"
exit "$CODE"

To use it, put it in front of whatever you were already running.

chmod +x run-marked.sh
./run-marked.sh /tmp/build.mark npm run build

Three choices deserve an explanation.

Why write to a temp name and then mv -f. Opening the same file directly with > truncates it for an instant. If the waiting side reads at that exact moment, it observes an empty state. Writing elsewhere and renaming means the reader always gets either the previous complete content or the new complete content. It is a one-line difference that matters once something is polling the file for minutes at a time.

Why send a heartbeat. "Stuck at running" and "still working, state running" look identical in the file. Rewriting the timestamp lets the waiting side notice that updates stopped.

Why keep the exit code. This is precisely what the notification cannot tell you. Storing the value of exit carries success and failure back to whoever is waiting.

The waiting side splits three states

The reader looks at the marker and separates finished, still running, and heartbeat lost.

#!/usr/bin/env bash
# wait-for.sh — read the marker and split it into three states
set -uo pipefail
MARK="${1:?pass the path to the marker file}"
TIMEOUT="${2:-1800}"   # upper bound on the wait, in seconds
STALE="${3:-10}"       # treat the job as lost after this many seconds without a heartbeat
START=$(date +%s)
 
field() { sed -n "s/.*\"$1\":\"\{0,1\}\([a-z0-9]*\)\"\{0,1\}.*/\1/p" "$MARK"; }
 
while :; do
  NOW=$(date +%s); ELAPSED=$(( NOW - START ))
  if [ -f "$MARK" ]; then
    STATE=$(field state); AT=$(field at); AGE=$(( NOW - ${AT:-NOW} ))
    case "$STATE" in
      done)
        CODE=$(field exit)
        echo "done exit=${CODE} waited=${ELAPSED}s"; exit "${CODE:-0}" ;;
      running)
        if [ "$AGE" -gt "$STALE" ]; then
          echo "stale no heartbeat for ${AGE}s waited=${ELAPSED}s"; exit 75
        fi ;;
    esac
  fi
  if [ "$ELAPSED" -ge "$TIMEOUT" ]; then echo "timeout ${TIMEOUT}s"; exit 124; fi
  sleep 1
done

Here is what three runs produced on my Linux box, with a 2-second heartbeat and STALE set to 8.

--- case 1: clean finish ---
done exit=0 waited=6s
waiter exit=0
--- case 2: failure (exit 3) ---
done exit=3 waited=4s
waiter exit=3
--- case 3: SIGKILL partway through ---
stale no heartbeat for 9s waited=10s
waiter exit=75

Case 3 is the whole reason for the marker. The wrapping process was killed, so done never gets written and no notification fires. The waiting side still exits after ten seconds, having concluded the heartbeat stopped. The file is left holding {"state":"running","pid":4,...}, so you can also read back where it stalled.

While the job is healthy, the timestamp advances every two seconds.

{"state":"running","pid":64,"at":1787378969,"exit":null}
{"state":"running","pid":64,"at":1787378971,"exit":null}
{"state":"running","pid":64,"at":1787378973,"exit":null}

The waiter's own exit code feeds straight into your next decision.

StateConditionExit codeWhat to do next
donestate is donethe job's own exit codebranch on success or failure
staleheartbeat stopped for longer than STALE75re-run it
timeoutoverall limit exceeded124stop waiting and investigate

75 is a conventional value for a temporary failure, which makes it easy to treat as "try again." 124 matches what the timeout command uses. The caller can decide between retrying and investigating by looking at the number alone.

Two knobs to turn for unattended runs

Interactively, the defaults are fine. Once this goes into a scheduled run, adjust two things.

Set STALE to three to five times the heartbeat interval. On CI containers and shared machines, a busy moment can swallow a single heartbeat. Setting it equal to the interval will call a healthy job stale. With a 2-second heartbeat, 8 to 10 seconds has been comfortable.

Set TIMEOUT to roughly twice the usual runtime. Too generous a limit means you find out things stalled the next morning. My image batch takes about ten minutes, so I use 1200 seconds.

If you want the same idea applied across a whole schedule — downstream work verifying for itself that upstream actually ran — that is the subject of the completion ledger and dependency barrier for unattended schedulers. It is the closer fit if your tasks chain across days.

Pick the single job you spend the most time waiting on today and wrap it in run-marked.sh. Even before you write the waiting side, you get a file you can read to see where it is. From there, days when the notification arrives and days when it doesn't start to look the same.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Claude Code2026-08-03
Existence Checks Pass, Writes Fail — Probing Capabilities Before an Unattended Run
A directory existing and a directory being writable are two different facts. Measured results from five broken-environment cases, and a capability-probe preflight for unattended Claude Code runs.
Claude Code2026-06-27
Will It Stay Light When You Run It Unattended? Observing and Capping Claude Code's Long-Session Memory
How to keep long, unattended Claude Code sessions from slowly getting heavier — with a tiny ps-based RSS sampler, a rolling-baseline watchdog, and session segmentation, shown with working scripts and a before/after comparison.
Claude Code2026-06-19
An Article My Gate Rejected Got Published — The Cost of Chaining the Quality Gate and git push in One Call
In an unattended publishing pipeline, an article my quality gate had rejected went live anyway. The cause was chaining the gate and git push into a single shell call. Here is how the exit code gets swallowed, and a two-phase publish-marker design that refuses to push until every gate has demonstrably passed.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →