Once you set up Claude Code notifications, you start using Claude more aggressively. More terminals, more tasks running in parallel. One fixing a bug, one writing tests, one refactoring a module.
And then your phone buzzes.
Action Required [pulse #a3f2]
You have three terminals open. Which one is that? What was it working on? You have no idea without switching to each terminal and checking.
This is the multi-terminal problem. And it's surprisingly easy to fix.
Why Random Hashes Are Useless
Claude Code identifies sessions with a UUID — something like d524f0c1-0e93-49d5-9603-e0320534259f. When you set up notifications, most examples trim that to the last four characters as a "tag", giving you things like #a3f2 or #b891.
That's better than nothing, but it still doesn't tell you anything meaningful. You can't look at #a3f2 and know it's the terminal that was fixing the auth bug. You have to go hunting.
What you actually want is something like this:
Action Required — pulse / S2 "add RBAC"
Now you know exactly which terminal needs you. You don't even have to look at your screen to know.
The Idea: A Session Registry
The fix is a small shared registry file that maps each session UUID to something human-readable.
Here's how it works:
- When a session starts, assign it a short label:
S1,S2,S3— sequential per project - When you type your first message, capture the first few words as a task summary
- Store both in a JSON file at
~/.claude/session-registry.json - Your notifications and statusline both read from this file
The result is that every terminal gets a unique, meaningful identity that matches up between your notification and your screen.
| Terminal | What you see in the statusline | What the notification says |
|---|---|---|
| Terminal 1 | S1 "fix auth bug" |
Action Required — pulse / S1 "fix auth bug" |
| Terminal 2 | S2 "add RBAC" |
Action Required — pulse / S2 "add RBAC" |
| Terminal 3 | S3 (no prompt yet) |
Action Required — pulse / S3 |
Building It
You need three pieces: a registry library, a hook that populates the registry, and updates to your existing notification script.
1. The Registry Library
This is the core. Save it to ~/.claude/hooks/session_registry.py.
#!/usr/bin/env python3
"""
Shared session registry for Claude Code.
Maps session UUIDs to human-readable labels like S1, S2, S3.
"""
import fcntl
import json
import os
import tempfile
from datetime import datetime, timezone
REGISTRY_PATH = os.path.expanduser("~/.claude/session-registry.json")
LOCK_PATH = REGISTRY_PATH + ".lock"
STALE_HOURS = 24
def _now_iso():
return datetime.now(timezone.utc).isoformat()
def _load(f):
try:
content = f.read()
return json.loads(content) if content.strip() else {"sessions": {}}
except (json.JSONDecodeError, AttributeError):
return {"sessions": {}}
def _save(data):
"""Atomic write — readers never see a partial file."""
dir_ = os.path.dirname(REGISTRY_PATH)
with tempfile.NamedTemporaryFile("w", dir=dir_, delete=False, suffix=".tmp") as tmp:
json.dump(data, tmp, indent=2)
tmp_path = tmp.name
os.rename(tmp_path, REGISTRY_PATH)
def _purge_stale(sessions):
"""Remove sessions not seen in the last 24 hours."""
cutoff = datetime.now(timezone.utc).timestamp() - (STALE_HOURS * 3600)
return {
sid: s for sid, s in sessions.items()
if datetime.fromisoformat(s["last_seen"]).timestamp() > cutoff
}
def _next_seq(sessions, project):
"""Find the next available sequence number for a project."""
used = {
s["seq"] for s in sessions.values()
if s.get("project") == project
}
seq = 1
while seq in used:
seq += 1
return seq
def register_session(session_id, cwd):
"""
Register a new session. Assigns it the next available S-number for its project.
Safe to call multiple times — skips if already registered.
"""
project = os.path.basename(cwd.rstrip("/")) if cwd else "unknown"
try:
lock = open(LOCK_PATH, "w")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
# Can't get lock — fall back silently
return
try:
with open(REGISTRY_PATH, "r") as f:
data = _load(f)
except FileNotFoundError:
data = {"sessions": {}}
sessions = data.get("sessions", {})
sessions = _purge_stale(sessions)
# Don't re-register an existing session
if session_id in sessions:
sessions[session_id]["last_seen"] = _now_iso()
data["sessions"] = sessions
_save(data)
return
seq = _next_seq(sessions, project)
sessions[session_id] = {
"seq": seq,
"project": project,
"label": f"S{seq}",
"task_summary": "",
"started_at": _now_iso(),
"last_seen": _now_iso(),
}
data["sessions"] = sessions
_save(data)
fcntl.flock(lock, fcntl.LOCK_UN)
lock.close()
def update_task_summary(session_id, prompt):
"""
Store the first prompt as the session's task summary.
Only updates if no summary exists yet.
"""
try:
lock = open(LOCK_PATH, "w")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
return
try:
with open(REGISTRY_PATH, "r") as f:
data = _load(f)
except FileNotFoundError:
return
sessions = data.get("sessions", {})
if session_id not in sessions:
return
# Only capture the very first prompt
if not sessions[session_id].get("task_summary"):
summary = prompt.strip().replace("\n", " ")[:40]
sessions[session_id]["task_summary"] = summary
sessions[session_id]["last_seen"] = _now_iso()
data["sessions"] = sessions
_save(data)
fcntl.flock(lock, fcntl.LOCK_UN)
lock.close()
def get_session_label(session_id):
"""
Returns a human-readable label for a session.
e.g. 'S1 "fix auth bug"' or falls back to '#a3f2' if not registered.
"""
try:
with open(REGISTRY_PATH, "r") as f:
data = _load(f)
except FileNotFoundError:
return f"#{session_id[-4:]}"
entry = data.get("sessions", {}).get(session_id)
if not entry:
return f"#{session_id[-4:]}"
label = entry.get("label", f"#{session_id[-4:]}")
summary = entry.get("task_summary", "")
if summary:
return f'{label} "{summary}"'
return label
The two things worth noting here: file locking with fcntl.flock makes sure two sessions starting simultaneously don't both claim S1. And atomic writes via os.rename means readers can never catch the file in a half-written state.
2. The Hook That Feeds the Registry
This is the thin script that Claude Code actually calls. Save it to ~/.claude/hooks/session_register.py.
#!/usr/bin/env python3
"""
Hook entrypoint for SessionStart and UserPromptSubmit events.
Populates the session registry with labels and task summaries.
"""
import json
import sys
import os
sys.path.insert(0, os.path.expanduser("~/.claude/hooks"))
import session_registry
def main():
try:
data = json.loads(sys.stdin.read())
except Exception:
return
session_id = data.get("session_id", "")
event = data.get("hook_event_name", "")
if not session_id:
return
if event == "SessionStart":
cwd = data.get("cwd", os.getcwd())
session_registry.register_session(session_id, cwd)
elif event == "UserPromptSubmit":
prompt = data.get("prompt", "")
cwd = data.get("cwd", os.getcwd())
# Auto-register in case SessionStart was missed
session_registry.register_session(session_id, cwd)
if prompt:
session_registry.update_task_summary(session_id, prompt)
if __name__ == "__main__":
main()
3. Update Your notify.py
You only need to change how the session tag is generated. Replace wherever you currently build the tag (the #{last-4-chars} part) with a call to get_session_label:
import sys
import os
sys.path.insert(0, os.path.expanduser("~/.claude/hooks"))
import session_registry
# Replace this:
tag = f"#{session_id[-4:]}"
# With this:
tag = session_registry.get_session_label(session_id)
And update your notification title accordingly:
notify(f"⚠️ Action Required — {project} / {tag}", f"Waiting to use: {tool}")
4. Register the New Hooks in settings.json
Add SessionStart and UserPromptSubmit to your hooks config in ~/.claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/session_register.py",
"timeout": 3
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/session_register.py",
"timeout": 3
}
]
}
]
}
}
What the Registry File Looks Like
Once a few sessions have run, ~/.claude/session-registry.json will look something like this:
{
"sessions": {
"d524f0c1-0e93-49d5-9603-e0320534259f": {
"seq": 1,
"project": "pulse",
"label": "S1",
"task_summary": "fix auth bug in login flow",
"started_at": "2026-02-21T01:47:00Z",
"last_seen": "2026-02-21T02:13:00Z"
},
"b45b5be6-bb5b-43df-b8ce-48d22a03d893": {
"seq": 2,
"project": "pulse",
"label": "S2",
"task_summary": "add RBAC to dashboard endpoints",
"started_at": "2026-02-21T01:52:00Z",
"last_seen": "2026-02-21T02:10:00Z"
}
}
}
Sessions older than 24 hours get purged automatically the next time a new session registers. No cron jobs needed.
Testing It
Open two terminals in the same project. In the first, type something like "fix the login bug". In the second, type "add unit tests for the auth module".
Now trigger a permission prompt in either terminal — ask Claude to run a bash command it hasn't run before. The notification should say something like:
⚠️ Action Required — pulse / S1 "fix the login bug"
You'll know exactly which terminal it's coming from without looking.
Extending It Further
Once the registry exists, you can use it for other things:
Better statusline — if you use the statusLine feature in settings.json, you can pull the session label from the registry and display it right in your terminal prompt. Then notifications and statusline show the same label, making it effortless to match them.
Usage tracking — log each session's task summary to a weekly file and you get a natural record of everything you've been using Claude for. Surprisingly useful for end-of-week reviews.
Context-aware hooks — route notifications differently based on what the session is doing. Critical tasks go to your phone, routine ones just make a sound.
The Bigger Point
The random hash wasn't a feature, it was a placeholder. Claude Code's hook system is powerful enough to build real session identity on top of it — you just have to wire it together yourself.
Once you have meaningful labels, multi-terminal Claude Code goes from chaotic to genuinely manageable. You stop babysitting terminals and start trusting the system to tell you when it needs you.
That's the whole point.