A session that closes has to prove it cleaned up
The process monitor said the audio daemon was at 340% CPU. That was the whole symptom, hours after the session that caused it had closed, on a machine that was otherwise fine. Underneath: 326 audio capture streams running at once, 37 GB of raw audio in a temp directory, and a maintenance loop in my dictation setup that had leaked one recorder process per minute since a session earlier that day. That session had ended normally, reported nothing, and left the damage behind. Nothing I had built had a step where a session answers for the machine it is leaving.
Sessions close all the time without a goodbye. A context window ends, the pane gets killed, the laptop sleeps, and whatever the session spawned either keeps running or stops, and either can be wrong. The locks other sessions must respect, the throwaway worktrees the night run cuts, recorder processes, uncommitted files: all of it is state that outlives the session, and no later session knows to look at it. It is the same failure as the report nobody reads, in a different costume.
- A rule no mechanism forces through your eyes is prose.
This is the mechanism for the end of a session.
The design is one script and two hooks, and the property I was after lives in the second hook: a skipped gate cannot stay skipped.
Session end runs a suite of checks and writes a marker file for the project: green with a timestamp, or debt with the findings. Session start reads the marker. Green and less than a week old means silence; nothing printed, the session begins. Missing, stale or red means the previous session skipped the gate or failed it, and the new session inherits the findings as injected context (a start hook’s output is context, that is the documented contract) and has to clear them and run the end check itself before doing anything else. A session that dies mid-flight leaves no marker, so the next one runs the suite on its behalf. There is no path through which the debt goes unseen, which is the only property that matters.
session end ──▶ run the suite ──▶ marker: ok, or debt + findings
│
session start ──▶ read the marker ◀──┘
├─ ok, under a week old ──▶ silence, the session begins
└─ missing, stale or debt ──▶ findings injected as context;
clear them, run "end", then work
The suite is deliberately small, and every check names a real incident. Capture streams above two: the 326-stream class. The temp directory over 512 MB or twenty files: the 37 GB class. A lock older than forty-eight hours: a session that died holding it. Uncommitted paths at session end: the wrap-up rule, previously a habit, now checkable. A sweep worktree older than five days: a crashed night. Here is a runnable version with the first three:
#!/bin/sh
# session-gate.sh end|start
MARK="$HOME/.agent-gate/$(basename "$PWD").json" # (1)
f=""
note() { f="$f - $1\\n"; }
suite() { # (2)
n=$(pgrep -f avfoundation | wc -l | tr -d ' ')
[ "$n" -gt 2 ] && note "AUDIO: $n capture streams (expect 1)"
kb=$(du -sk /tmp/capture-live 2>/dev/null | cut -f1)
[ "${kb:-0}" -gt 512000 ] && note "TMP: capture-live ${kb}KB"
d=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
[ "$d" -gt 0 ] && note "REPO: $d uncommitted paths"
}
mark() { # (3)
mkdir -p "${MARK%/*}"
state=ok; [ -n "$f" ] && state=debt
printf '{"state":"%s","when":"%s","findings":"%s"}\n' \
"$state" "$(date -u +%FT%TZ)" "$f" >"$MARK"
}
case "$1" in
end) suite; mark ;;
start) [ -f "$MARK" ] || { suite; mark; } # (4)
fresh=$(find "$MARK" -mtime -7) # (5)
grep -q '"state":"ok"' "$MARK" && [ -n "$fresh" ] && exit 0
echo "SESSION-GATE DEBT: the previous session left work."
echo "Clear it, then run: session-gate.sh end"
sed -n 's/.*"findings":"\(.*\)".*/\1/p' "$MARK" |
sed 's/\\n/\n/g' ;; # (6)
esac
- The marker is per project, so debt greets the session that comes back to the place it was made, not a stranger in an unrelated repo.
- One line per check, each named after a real incident; the suite grows by postmortem, never by imagination.
- Written on every end, green or red, so an absent marker can only mean a session that never reached its end.
- A start with no marker runs the suite itself, on the dead session’s behalf.
- Green expires after a week; an old green is not evidence.
- The findings print verbatim, and a start hook’s output is injected context, so the debt is the first thing the new session reads.
Wire end to your agent’s session-end hook and start to its
session-start hook, and it runs on every session from then on. Had it
existed that day, the next session would have opened on this:
$ session-gate.sh start
SESSION-GATE DEBT: the previous session left work.
Clear it, then run: session-gate.sh end
- AUDIO: 326 capture streams (expect 1)
- TMP: capture-live 38797312KB
One thing about the shape. The checks are fail-soft and fast; the gate is not there to stop a session from ending, it is there to make the next session’s first screen tell the truth.
What I would generalise: any resource a session can leave behind needs a counter somewhere, and the session boundary is the cheapest place to read it. The count that told the truth that day was capture streams, 326 of them, and that count now runs at every session end, whether or not the session remembers to.