a wristwatch for your agent is really helpful
(this blog post is mostly written by claude fable, based on conversations between me nevyn and it).
An LLM session has no clock. The model experiences the conversation as one continuous now: you ask it something at midnight, close the laptop, come back after lunch, and as far as it can tell no time has passed at all. Whatever it believed about the world at midnight — which branch it was on, that the build was green, that the file it just wrote is still the newest thing in the repo — it still believes, with full confidence, fourteen hours later.
Mostly that’s fine. But I run long-lived Claude Code sessions, sometimes across days, and the same failure kept recurring: the model reasoning happily from a snapshot of a world that stopped being true while I slept. It would say “today” about yesterday. It would keep stacking commits on a branch I’d already merged from another machine.
Nothing in its context suggests that starting server surgery over ssh over a phone hotspot might be a bad idea, because nothing in its context says “phone hotspot” — more on that one in a moment, since it’s the best save the hook has made so far.
So I gave it a watch. And then, since I was in the neighborhood anyway, a little more than a watch.
Two mornings where it earned its keep
First, some examples of where this mechanism saved my bacon; scroll down for the actual scripts and hooks.
This morning I opened a session in my server-config repo that I hadn’t touched in a while. The hook stamped my first prompt with:
<ctx>2026-08-05 07:57 CEST Wed, +400h26m | Alecto | 77% discharging | net hotspot (phone) | raheth@main 2 dirty</ctx>
Four hundred hours. The model’s first words were “Morning! Let me quickly re-verify state — it’s been 2.5 weeks and there are new files in the repo since we last talked” — and the re-verify actually mattered, because in those 2.5 weeks a scheduled recon job had fired and left behind a full upgrade plan for the server that neither of us (in this session’s memory) had ever seen. It found the plan, summarized where things actually stood, and then added, unprompted:
One consideration if you want to do the upgrade now: you’re on a phone hotspot on battery. The upgrade script runs detached on the server so a connection drop mid-run isn’t dangerous, but the backup/verify steps want a stable session — might be nicer from a real connection.
Nothing in my prompt mentioned the hotspot; that came entirely from the 77% discharging | net hotspot (phone) fields. We did the upgrade anyway, but the warning changed how: everything ran inside tmux on a desktop machine at home, so a hotspot blip would cost a re-attach instead of a half-finished backup. Without the stamp, the first sign of trouble would have been the SSH connection dying mid-verify.
The day before, same thing in miniature. A planning session from Tuesday evening, reopened Wednesday morning:
<ctx>2026-08-05 08:03 CEST Wed, +15h31m | Alecto | 73% discharging | net hotspot (phone)</ctx>
First words: “Quick state check so the advice matches reality (big gap since yesterday)”. It re-checked the repos, confirmed that two feature branches had merged since its last look, and only then planned the day — against the world as it was, not as the transcript remembered it. A model without the gap would have given the same advice off yesterday’s state, confidently, and some of it would have been wrong.
The mechanism
Claude Code has hooks — shell commands that run at points in the agent’s lifecycle. The one that matters here is UserPromptSubmit: it runs every time you submit a prompt, gets a bit of JSON on stdin (session id, working directory), and whatever it prints to stdout gets injected into the model’s context alongside your message. That’s the entire trick. The rest of this post is really just about deciding what’s worth printing, which turned out to be the interesting part.
Mine prints one line, and only sometimes:
<ctx>2026-08-05 22:30 CEST Wed | Alecto | 31% charging | net unknown (gw aa:bb:cc:dd:ee:ff)</ctx>
Pipe-separated: timestamp, hostname, and then a handful of fields that only appear when they have something to say — battery, network location, the git state of the directory we’re standing in, and whether my config-sync repo is in a conflicted state.
The gate is most of the point
The script does not print on every prompt. It keeps a tiny state file per session (keyed by session id, in $TMPDIR), and if less than ten minutes have passed since the last stamp, it prints nothing and exits.
Partly this is about tokens, but mostly it’s about habituation. A stamp on every prompt becomes wallpaper; the model tunes it out the way you tune out a ticking clock. Gated, the stamp’s mere appearance is information: time has passed. A fresh session always opens with one, since there’s no previous timestamp to gate against.
And when it fires after a real gap, the gap itself gets printed:
<ctx>2026-08-06 09:12 CEST Thu, +10h42m | Alecto | ...</ctx>
That +10h42m is the most load-bearing part of the whole line. “It is now Thursday morning” is trivia; “ten hours passed since you last looked at anything” is an instruction, or at least it becomes one, because my CLAUDE.md tells the model what a big gap means: re-verify build and branch state before reasoning from memory. The world may have moved. It usually has.
Fields that appear only when they mean something
Battery and network follow the same attention-economy logic. The state file remembers the last power state and network label, and the field is only printed when it changed — with one exception, discharging, which prints every stamp, because a laptop draining is a fact you want standing in the room.
The network label is my favorite dumb trick in the script: there’s no API for “where am I”, but your default gateway’s MAC address is a fingerprint for the network you’re on. route -n get default for the gateway IP, arp -n for its MAC, then a lookup in a little hand-maintained table mapping MACs to labels like “home” or “office”. iPhone hotspots don’t need an entry at all — they hand out a recognizable subnet, so those become hotspot (phone) by prefix match. An unlisted network prints unknown (gw <mac>), which doubles as the enrollment flow: visit somewhere new, the MAC shows up in the stamp, paste it into the table with a label. The point of all this is the combination: discharging plus hotspot (phone) should make an agent reconsider kicking off anything long and network-hungry, and now it can.
Git state, or: did someone push behind my back
The git field packs a surprising amount into a few words:
myrepo@feature/foo [worktree] REBASING 3 dirty, ahead 2, behind 1
Repo and branch from rev-parse; a [worktree] marker by comparing the realpaths of --git-dir and --git-common-dir (they differ exactly when you’re in a linked worktree); rebase/merge/cherry-pick detected the way git itself does it, by marker files in the git dir; dirty count and ahead/behind parsed out of status --porcelain=v1 -b, whose header line carries them for free.
This field earns its place because agents are shockingly confident about repo state they remember rather than observe. I move between machines. I push things mid-session from another terminal. Worktrees are their own special footgun — a session that doesn’t know it’s standing in one will happily reason about “the” checkout. Having the actual state ambient in context, refreshed every stamp, catches a whole family of stale-assumption bugs before they start.
The legend lives in the system prompt
The stamp can afford to be terse because it isn’t self-describing — the decoder ring lives in CLAUDE.md, next to a sentence that turned out to be essential: these blocks are ambient, no need to address them unless relevant. Without that line, the model comments on your battery percentage like a nervous flight attendant. With it, the information just sits there, shaping decisions when it matters and ignored when it doesn’t, which is more or less what ambient means.
Fail soft
Every probe is wrapped in a try/except that returns None; subprocesses get a 1.5 second timeout; anything missing just shrinks the line. Desktops have no battery, offline machines have no gateway, most directories aren’t repos, and none of that should matter — a hook that can block or garble prompt submission is much worse than a missing stamp.
Keeping two machines honest
The stamp says Alecto because there are two of these machines — Alecto the laptop, miquon the desktop — and the whole arrangement only works if the hook, the CLAUDE.md legend that decodes it, and the rest of the config are identical on both. That’s its own little sync system, and it dovetails with the hook in a way I didn’t plan but quite like.
The config lives in a git repo (~/Dev/ClaudeSynced); ~/.claude reaches it through symlinks that an install script sets up on a new machine. A SessionEnd hook — the bookend to UserPromptSubmit — commits everything as sync (hostname), does pull --rebase --autostash, and pushes. Every step is best-effort with output appended to a log, because a sync hook that can wedge session teardown is the same bad trade as a stamp hook that can wedge prompt submission.
Two wrinkles were non-obvious. Claude Code rewrites its own settings via write-temp-then-rename, which quietly replaces the symlink with a regular file — so the sync hook checks each config path, and where it finds a real file it folds the content back into the repo and re-links. And when the rebase hits a real conflict, nothing automatic is safe anymore; the repo just sits there mid-rebase. Which is where the last field of the stamp comes in: sync_line() checks for rebase/merge markers in the sync repo, and prints CONFIG-SYNC CONFLICT if it finds them. The model itself becomes the alarm bell, mentioning it every stamp until I fix it — which is, more or less, the only notification channel I actually read.
The script
Registered in ~/.claude/settings.json:
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "$HOME/.claude/hooks/timestamp-context.py" }
]
}
]
}
And the script itself, in its entirety (chmod +x it):
#!/usr/bin/env python3
# UserPromptSubmit: when >10min elapsed since last stamp (per session), inject
# one pipe-separated <ctx> line: timestamp (+gap), host, battery and network
# (only on change, or while discharging), git state of cwd (worktree, rebase,
# ahead/behind), config-sync health. Legend lives in CLAUDE.md (Working rules).
import sys, json, os, re, time, socket, subprocess
from datetime import datetime
GATE = 600 # seconds
# Gateway MAC -> location label. At an unlisted site the stamp shows
# "unknown (gw <mac>)"; paste that MAC here with a label. iPhone hotspots
# are detected by subnet, no entry needed.
GATEWAYS = {
# "aa:bb:cc:dd:ee:ff": "home",
}
def run(*argv, timeout=1.5):
return subprocess.run(argv, capture_output=True, text=True,
timeout=timeout).stdout
def battery(): # -> (pct, state) or None on desktops
try:
m = re.search(r"(\d+)%; ([^;]+)", run("pmset", "-g", "batt"))
return (m.group(1), m.group(2).strip()) if m else None
except Exception:
return None
def net_label():
try:
gw = re.search(r"gateway: ([\d.]+)", run("route", "-n", "get", "default"))
if not gw:
return None
if gw.group(1).startswith(("172.20.10.", "192.168.43.")):
return "hotspot (phone)"
mac = re.search(r"at ([0-9a-f:]+)", run("arp", "-n", gw.group(1)))
if not mac:
return None
return GATEWAYS.get(mac.group(1), f"unknown (gw {mac.group(1)})")
except Exception:
return None
def git_line(cwd):
try:
info = run("git", "-C", cwd, "rev-parse", "--path-format=absolute",
"--show-toplevel", "--git-dir", "--git-common-dir").splitlines()
if len(info) < 3:
return None
top, gitdir, common = info
head, *entries = run("git", "-C", cwd, "status", "--porcelain=v1",
"-b").splitlines()
branch = head[3:].split("...")[0]
if branch.startswith("HEAD"):
branch = "detached"
name = f"{os.path.basename(top)}@{branch}"
if os.path.realpath(gitdir) != os.path.realpath(common):
name += " [worktree]"
for marker, flag in (("rebase-merge", "REBASING"),
("rebase-apply", "REBASING"),
("MERGE_HEAD", "MERGING"),
("CHERRY_PICK_HEAD", "CHERRY-PICKING")):
if os.path.exists(os.path.join(gitdir, marker)):
name += " " + flag
break
parts = [f"{len(entries)} dirty" if entries else "clean"]
if "[" in head: # "## main...origin/main [ahead 1, behind 2]"
parts.append(head.rsplit("[", 1)[1].rstrip("]"))
return f"{name} {', '.join(parts)}"
except Exception:
return None
def sync_line():
g = os.path.expanduser("~/Dev/ClaudeSynced/.git")
if any(os.path.exists(os.path.join(g, p))
for p in ("rebase-merge", "rebase-apply", "MERGE_HEAD")):
return "CONFIG-SYNC CONFLICT in ~/Dev/ClaudeSynced (automemory-sync.log)"
return None
data = json.load(sys.stdin)
sid = data.get("session_id", "default")
state = os.path.join(os.environ.get("TMPDIR", "/tmp"), f"cc-ts-{sid}")
now = time.time()
prev = {}
try:
raw = open(state).read().strip()
prev = json.loads(raw)
if not isinstance(prev, dict):
prev = {"t": float(prev)} # pre-JSON state files held a bare float
except (FileNotFoundError, ValueError):
pass
last = prev.get("t")
if last is not None and now - last < GATE:
sys.exit(0) # within gate: inject nothing
ts = datetime.now().astimezone()
stamp = ts.strftime("%Y-%m-%d %H:%M %Z %a")
if last is not None:
gap = int(now - last)
stamp += f", +{gap // 3600}h{(gap % 3600) // 60:02d}m"
fields = [stamp, socket.gethostname().split(".")[0]]
batt = battery()
if batt and (batt[1] != prev.get("power") or "discharg" in batt[1]):
fields.append(f"{batt[0]}% {batt[1]}")
net = net_label()
if net and net != prev.get("net"):
fields.append(f"net {net}")
for f in (git_line(data.get("cwd") or os.getcwd()), sync_line()):
if f:
fields.append(f)
with open(state, "w") as f:
json.dump({"t": now, "power": batt[1] if batt else None, "net": net}, f)
print(f"<ctx>{' | '.join(fields)}</ctx>")
(The sync_line bit checks whether the git repo holding my synced config is stuck mid-merge, which is specific to my setup — rip it out, or repoint it at whatever repo you’d want to be warned about.)
The macOS-isms — pmset, route/arp output formats — would need swapping on Linux, but each probe is a small function returning a string or None, so the surgery is contained.
I keep being tempted to add more fields. Calendar awareness feels like it belongs here, maybe CI status. But every field has to pay rent in attention, and so far these are the ones that have.
comments