# Agent Session Ledger — on-disk contract

Audience: AI coding agents first.

Durable index of every AI CLI session launched on this
workstation. Terminal death, process death, and reboot MUST NOT lose an entry. Consumers read
this contract; they MUST NOT re-derive liveness rules of their own.

## Locations

| Path | Writer | Content |
|---|---|---|
| `$AGENT_SESSIONS_DIR` (default `~/.local/state/agent-sessions`) | — | ledger root, mode 0700 |
| `<root>/sessions/<ledgerId>.json` | launch shim, Claude hooks | one entry, schemaVersion 1 |
| `<root>/sock/<ledgerId>` | dedicated `tmux` server (legacy rows may name `dtach`) | session socket; absent when unwrapped |
| `~/.local/state/human-session/tmux.sock` | `_human-session` | tmux server for the user's own sessions |
| `<root>/notified.json` | notifier | edge-trigger dedupe state |

Every write MUST be tmp-file + `rename` into place. Never partial-write an entry. Entry updates
use a sidecar lock whose complete owner identity (`bootId`, PID, process start ticks, unique token)
is published atomically before it can become visible. Ordinary writers never unlink another
writer's lock. The systemd discovery sweep, serialized with reap by
`%t/overdeck-agent-scanners.lock`, removes a lock only when its identity proves the owner process
is dead; an unreadable or malformed lock is never guessed stale. `agent-reaper` remains outside
this lock so session maintenance can never suppress its enforcement pass. One unavailable entry is
reported in `sweep().updateFailures` and does not abort updates for the rest of the ledger.

## Enrolment

Three points write birth records. A session enrolled by any one is never re-enrolled by another
— identity is `(pid, pidStartTicks)`, plus `AGENT_LEDGER_ID` in the process environment where the
shim set one.

| Point | Writer | Covers |
|---|---|---|
| PATH shim (every `~/.claude/bin` symlink to `_tmpjail-shim.sh`) | `lib/agent-session-ledger.sh` | launches that resolve through `~/.claude/bin`; interactive launches are hosted in a dedicated, systemd-owned tmux server |
| Claude hook adoption (`SessionStart` / `Stop` / `SessionEnd`) | `enrollProcess()` | every `claude` launch, whatever started it — `cld`, direct binary, headless `-p`, resume |
| Discovery sweep (`agent-sessions sweep`, `agent-session-sweep.timer`, 60 s) | `sweep()` | every covered runtime's process on this box, including ones that were already running before enrolment existed |

Arm the sweep on a host by linking its units out of the deploy clone, so a later deploy keeps
them current:

```sh
D=~/.local/share/overdeck/deploy/modules/workstation/claude/systemd/user
ln -sfn "$D/agent-session-sweep.service" ~/.config/systemd/user/
ln -sfn "$D/agent-session-sweep.timer" ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now agent-session-sweep.timer
```

The sweep asks the kernel, not the launcher: it matches `basename(argv[0])` against `RUNTIMES`
exactly. A PATH shim is NOT a choke point — it is one launcher among several.

The shim's tmux server is a collected systemd service in `agent.slice`, while its pane still
enters through `_agent-build-scope`; this keeps the runtime in the same `agent.slice` /
`confine-agent-*.scope` placement and with the same ceilings it had before terminal hosting.
The arrangement is deliberately the local analogue of `run-remote`: the server, rather than
the attaching terminal, owns the long-lived process tree. A direct launch that bypasses the
shim cannot be moved into a multiplexer after it has started, but the hook/sweep still enrols
it, and ancestor discovery records a tmux target when its caller already supplied one.

The shim writes the birth row before dispatch. Direct and human launches place
`_agent-session-runtime` as the final command inside confinement (and inside `tmpjail` when
present). Dedicated tmux launches cross the isolated seat-authority boundary without that wrapper:
after the tmux server has accepted the runtime client, the root descriptor broker authenticates that
client's kernel PID, holds its runtime executable descriptor, and blocks the final exec. The
authority publishes that PID/start ticks with the accepted mux target, then releases the descriptor;
the same process becomes the runtime before its first instruction. The runtime receives
`AGENT_LEDGER_ID`, but never the ownership marker, so descendants retain parent identity without
gaining authority to rewrite the operator row. Direct and human lifecycle wrappers record their own
PID and start ticks, then `exec` the next carrier. All paths therefore keep normal exit-status and
targeted-signal semantics. Once a direct or human birth row exists, binding failure refuses the
run without recovery evidence; a birth that could not write any row remains fail-open. A dedicated
seat launch is stopped if the authority cannot publish its runtime identity and accepted route.
`AGENT_LEDGER_OWN_ID` must equal
`AGENT_LEDGER_ID` before this bind; a nested shim clears the ownership marker before even loading
the ledger library. If child allocation fails it also drops the inherited ledger id before exec,
so same-runtime discovery cannot adopt the child as the parent's carrier or mutate/resurrect that
parent row.

Both writers MUST emit the identical field set. `agent-session-ledger.test.sh` pins this.

**Fail-closed rule — the sweep MUST NOT enrol a process whose `/proc/<pid>/environ` it cannot
read.** That file is ptrace-gated across bwrap user namespaces, so an unreadable environment
means "cannot prove this session is not already enrolled", and a duplicate row is a lie. Those
pids come back in `sweep().unverifiable`. Run the sweep from the host user namespace
(`/proc/self/uid_map` == `0 0 4294967295`) — the systemd --user timer does.

Coverage gaps, stated rather than papered over:

- A session on another host. The ledger is per-box.
- A runtime not in `RUNTIMES`, or one whose `argv[0]` basename is not its own name.
- `sessionId` / `transcriptPath` for a `codex` or `cursor-agent` session: those runtimes have no
  hook that reports one, and `claude` reports one only from its own hooks or an explicit
  `--resume <uuid>`. Absent, never guessed.

## Entry schema (schemaVersion 1)

```json
{
  "schemaVersion": 1,
  "ledgerId": "claude-20260807T110001Z-482913",
  "runtime": "claude",
  "startedAt": "2026-08-07T11:00:01Z",
  "host": "e14",
  "bootId": "0f0e…",
  "cwd": "/home/user/Projects/overdeck",
  "repoRoot": "/home/user/Projects/overdeck",
  "project": "overdeck",
  "branch": "main",
  "worktree": false,
  "tty": "/dev/pts/17",
  "launcherPid": 2253113,
  "parentLedgerId": null,
  "parent": null,
  "launchedBy": "user",
  "account": null,
  "enrolledBy": "shim",
  "mux": { "kind": "tmux", "socket": "/home/user/.local/state/agent-sessions/sock/claude-…", "target": "main" },
  "tmuxSession": "main",
  "tmuxSocket": "/home/user/.local/state/agent-sessions/sock/claude-…",
  "pid": null,
  "pidStartTicks": null,
  "cpuTicks": null,
  "sessionId": null,
  "transcriptPath": null,
  "processDescribedAt": null,
  "lastActiveAt": null,
  "lastHeartbeatAt": null,
  "finishedAt": null,
  "finishReason": null
}
```

Field rules:

- `ledgerId` — stable identity for dedupe. Format `<runtime>-<YYYYmmddTHHMMSS>Z-<6 digits>`. NEVER key dedupe on `pid` (reused) or on a recomputed timestamp.
- `mux.kind` — `"dtach"`, `"tmux"` or `null`. Discovery reads it from the process's ancestors: a `dtach` ancestor in a session-creating mode (`-c`/`-A`/`-n`/`-N`) names its socket as argv[2], and a `tmux: server` ancestor is queried over its `-S` socket for the session whose pane pid is in the chain — so a session the launcher never reported as wrapped is still reattachable. `null` means the session sits in no multiplexer (no multiplexer binary, non-tty launch, or a launcher that predates the wrap); no multiplexer can be attached to it after the fact, and readers MUST treat `null` as normal, never as an error.
- `processKey` — `<bootId>:<pid>:<pidStartTicks>`, from `classify()` and `processKey(entry)`. Unique per running process and immune to PID reuse: two rows sharing one are the same session, and two rows sharing a `sessionId` but not a `processKey` are two processes over one conversation (a resume), which is history, NOT a duplicate. It is `null` for any row with no live process behind it (finished, or enrolled before its pid was known) — a missing key is not a match.
- `tmuxSession` / `tmuxSocket` — the concrete pane target used by the collector. Set only after
  the tmux server has accepted the command: dedicated sessions are published by the privileged
  seat authority through its locked operator-ledger update, while direct sessions may be bound by
  `_agent-session-runtime`; discovery may also derive them from a live tmux ancestor. NEVER written
  at birth: a launch that fails to reach tmux must understate, never claim a host that does not
  exist. Present ⇒ the session is watchable and reopenable through that tmux server; absent or
  `null` ⇒ there is no tmux target. Mutually exclusive with `mux.kind: "dtach"`.
- `pid` / `pidStartTicks` — cache only. `pidStartTicks` is field 22 of `/proc/<pid>/stat`; a mismatch means PID reuse and the cache MUST be discarded.
- `sessionId` / `transcriptPath` — filled by the runtime's own hooks when it has them (Claude does). `null` otherwise; consumers fall back to CPU evidence. The hook fills them on ANY event, not at enrolment alone — a session discovered mid-flight has no session id until it next speaks. NEVER bind them from a subagent's transcript (`isSidechain: true` on its first line).
- `startedAt` — the process's real birth in UTC, `/proc/stat` `btime` + `/proc/<pid>/stat` field 22. NEVER `now` at enrolment (an hours-old session would read as new) and NEVER local time labelled `Z` (ages render negative).
- `launchedBy` — `"user"` | `"factory"` | `"agent"`. At shim birth,
  `FACTORY_ADW=1` proves `factory`, while an inherited `AGENT_LEDGER_ID` proves
  `agent` and names its parent before the systemd-owned tmux server breaks the Unix
  ancestor chain. Otherwise the nearest ancestor that names it: a `RUNTIMES`
  ancestor ⇒ `agent`; a terminal/tmux/sshd ancestor or an interactive cgroup scope
  ⇒ `user`, but ONLY when the environment was readable — an unreadable environment
  cannot rule out a factory launch, so it yields `null`. No evidence ⇒ `null`.
  NEVER default to a guess.
- `parent` — the launching agent's `ledgerId` when it is itself enrolled, else `"<runtime> (pid N)"`. Set only when `launchedBy == "agent"`.

`AGENT_LEDGER_ID` reaches every descendant, so several processes carry one id and a runtime binary also runs services (`claude daemon run`). Two rules keep one row per session:

- A process is only enrolled when it is a session: argv[1] in `daemon`, `mcp`, `install`, `update`, `doctor`, `config`, `migrate-installer`, `setup-token`, `login`, `logout`, `completion` is a service or a maintenance job and is skipped.
- The covered runtimes (`RUNTIMES`) MUST be derived from the `_tmpjail-shim.sh` symlink set in `~/.claude/bin`, never written as a literal — that set is what the shim enrols under, so a CLI symlinked there later is covered with no code change, and a literal would reject its carrier by name and read every live session of it as dead. A CLI reachable on PATH WITHOUT a shim symlink produces no row at all and is invisible here; cover it by symlinking it to the shim, never by special-casing it downstream.
- A record binds to the OUTERMOST carrier of its id that runs the record's own runtime. A process not running that runtime is never the session — not a launcher shell, not a wrapper, not another runtime — and a carrier below another carrier of the same runtime is that carrier's child. Where the record names no runtime, the carrier tree is all there is to go on. An inherited id is adopted as a record's identity ONLY when that record has no process yet and its `launcherPid` is this process or one of its ancestors.
- `account` — seat slug from `CLAUDE_CONFIG_DIR` / `CODEX_HOME` / `HOME` matching `/accounts/<slug>/`. `null` on the default account — absence is NOT "unknown account".
- `enrolledBy` — `"shim"` | `"hook"` | `"discovery"`. Provenance of the row, not a state.
- `transcriptPath` — `~/.claude/projects/<slugged cwd>/<sessionId>.jsonl` when that file exists; otherwise the same file name is looked up across every project directory, because a session files its transcript under the directory it opened in, not necessarily the recorded `cwd`. `null` only when no transcript with that session id exists anywhere.
- `cpuTicks` / `lastActiveAt` — consecutive sweeps compare `utime+stime`; growth stamps `lastActiveAt`, but ONLY for an entry with no `transcriptPath`. A runtime sitting at its prompt still burns ticks, so for `claude` the stamp would report every open session as working with zero idle time; there the transcript mtime is the exact record and tick growth is ignored. Tick-derived activity resolves no finer than the timer interval (60s).
- `processDescribedAt` — the kernel/process-tree enrichment pass has run for this row. Discovery writes it immediately; legacy rows are enriched at most 32 per sweep. This bounds multiplexer and repository subprocess churn while eventually migrating every old row.
- `finishedAt` non-null ⇒ ended. Its absence is what makes a dead session ORPHANED. Claude has an end-of-session hook; hookless runtimes are closed by the sweep after kernel evidence proves their process is gone. A shim row whose launcher has exited before any PID, session, transcript, or multiplexer evidence was recorded is removed under its entry lock: it contains nothing recoverable, and retaining one such row per short command caused 162,780 permanent LOST records in four days. The entry-lock predicate is evaluated against the latest on-disk row, so a concurrent hook or discovery update that adds recovery evidence prevents removal. A bound hookless runtime is closed with `finishReason: "process exited"`. A dead `claude` entry is closed too, but ONLY when it once carried a `pid` and holds neither a `sessionId` nor a `transcriptPath` (`finishReason: "process exited, nothing was recorded to recover"`).

- `finishReason` — free text, for display. The `claude` SessionEnd hook translates the runtime's documented `reason` vocabulary into this ledger's prose (`prompt_input_exit` → `exited at the prompt`); a token it does not know, and `resume` whose direction the runtime does not document, MUST be passed through verbatim — never dropped, never glossed into a meaning the runtime did not state. `other` is Claude Code's documented catch-all and no other payload field narrows it, so it states no reason: the hook MUST write `null` there, exactly as it does when the payload carries no `reason` at all. A closed row with `finishReason: null` is normal — `finishedAt`, not this field, is what makes a row ended.

- `rescuedAt` / `rescueRef` / `rescueCommit` / `rescuedPaths` — written once by the rescue (below), never by anything else. `rescuedAt` non-null is what makes the rescue edge-triggered: an entry carrying it is NEVER snapshotted again, however many sweeps see it.

Unknown fields MUST be preserved on rewrite. Add fields only; bump `schemaVersion` to remove or retype one.

## Liveness states

Classifier: `~/.claude/lib/agent-session-reader.mjs` (`readEntries`, `classify`). Consumers MUST call it rather than reimplementing.

| State | Evidence |
|---|---|
| `ALIVE-WORKING` | process resolved AND (progress within idle threshold OR — for a transcript-less runtime only — CPU ticks grew over the sample) |
| `ALIVE-IDLE` | process resolved, no progress within idle threshold |
| `DETACHED-ALIVE` | process resolved, session socket exists, no client attached — recoverable in one command |
| `ORPHANED` | process gone, `finishedAt` null — this is the lost-work case |
| `FINISHED` | `finishedAt` non-null |

Thresholds (single source: `agent-session-reader.mjs` constants):

- `CPU_SAMPLE_MS = 1200` — two `/proc/<pid>/stat` reads; any utime+stime tick growth ⇒ working.
- `IDLE_MS = 600000` (10 min) — no CPU growth and no transcript mtime/size growth for 10 min ⇒ idle.

Consequence, stated rather than hidden: a `claude` session running one tool call longer than
`IDLE_MS` appends nothing to its transcript for the duration and reads `ALIVE-IDLE`. CPU growth
does NOT override this, because a session parked at its prompt burns ticks the same way — the
tick signal cannot tell the two apart, and reporting every open session as working is the worse
error. Evidence text says `cpu ticks accumulating` only where that signal actually set the state.

Process resolution, in order: `/proc/*/environ` carrying `AGENT_LEDGER_ID=<ledgerId>`, then the
entry's own `pid` validated against `pidStartTicks`. Both are immune to PID reuse; the second
also works across user namespaces, where `environ` is unreadable. `tty` gone is evidence text
only — NEVER a state signal.

`classify()` also returns, per entry — derived on every read, never stored stale:

| Field | Source |
|---|---|
| `title` | first user message, from a bounded 64 KB window at the head of the transcript |
| `activity` | last assistant tool use or text, from a bounded 64 KB window at the tail |
| `lastActiveAt` | transcript mtime when there is a transcript (with `lastHeartbeatAt`); otherwise the sweep's `lastActiveAt`; `startedAt` only when nothing else exists |

Transcripts reach 145 MB and are READ-ONLY. NEVER read one whole.

Known limits:

- `attached` for an unwrapped entry (`mux.kind` null) is a `/dev/pts/<n>` existence check, and pts numbers recycle. Treat it as display only. NEVER branch a state on it — tmux uses its live attached-client count and legacy dtach rows use socket-client detection.
- A tmux-hosted session retains pane history for capture and reattach while it is running.
- The attaching client exits 0 regardless of the agent's exit code. Only interactive launches are hosted, so scripted (`-p`, non-tty) exit codes are unaffected.

## Uncommitted work

`git --no-optional-locks -C <cwd> status --porcelain`, line count. Reading is read-only: consumers MUST NOT commit, stash, or clean. `classify()` deduplicates this work per distinct directory, and cleanup-only `agent-sessions reap` disables it entirely because no reap decision uses `dirtyCount`. Reap also probes at most 8 dedicated tmux sockets per pass, persisting each row's last-probed time and selecting the oldest; unprobed sessions are treated as attached and therefore kept until checked. Orphan tmux-scope cleanup likewise inspects at most 64 generation-named scopes per pass and persists a rotating cursor; a large unit backlog cannot turn one reap into hundreds of `systemctl show` processes. Reap waits up to two minutes for an active discovery sweep rather than silently skipping its enforcement pass. This bounds process churn without weakening attachment or identity checks. `rescueUncommitted()` in `agent-session-reader.mjs` is the ONE sanctioned mutator, and it only ever creates a ref.

## Rescue

A session whose process is gone gets its uncommitted work snapshotted to `refs/rescued/<ledgerId>`, from `sweep()`, at the moment the sweep finds the pid gone.

Rescue MUST NOT touch the working tree, the index, or HEAD. The snapshot is built through a throwaway index (`GIT_INDEX_FILE` under the ledger root → `add -A` → `write-tree` → `commit-tree -p HEAD`), so:

- a resumed session finds its files exactly as it left them — `git status --porcelain` is byte-identical across a rescue;
- no commit hook runs, so no security gate stands between lost work and its snapshot;
- untracked files are captured, which `git stash create` drops. `.gitignore`d files are not.

Rules:

- Trigger inside `sweep()`, NEVER as a later pass. `classify()` sets `dirtyCount: null` on FINISHED rows, and the sweep closes every hookless runtime's row the moment its pid is gone — a post-hoc rescuer would see nothing for `codex` / `cursor-agent`, the runtimes that never report ORPHANED.
- Rescue ONLY a row that recorded a pid. A row enrolled before its process was observed carries `pid: null` and is indistinguishable from a dead one; snapshotting it would set `rescuedAt` on a LIVE session and, being edge-triggered, silently disarm the rescue for the death that matters.
- The ref lands in the shared ref store (`--git-common-dir`), so a rescue taken inside a linked worktree outlives `git worktree remove` — which is how the worktree of a dead session normally disappears.
- Edge-triggered on `rescuedAt` (see field rules). `git update-ref <ref> <sha> ""` — empty old-value means "must not already exist" — makes two overlapping sweeps fail closed rather than clobber.
- Rescue the main checkout too, not worktrees alone: main-checkout WIP is what breaks a deploy built from the working tree. It is shared with live sessions, so the snapshot may hold work that was not this session's — the commit message says so.
- Restore with `git -C <cwd> cherry-pick -n <ref>`. NEVER `git stash apply`: the snapshot is a plain single-parent commit, not a stash's multi-parent object.
- `refs/rescued/*` is NEVER pruned, including when the 30-day row prune removes its entry. The feature exists so work is not lost; objects are cheap.

Both readouts surface it: `agent-sessions` prints the ref and the restore command, and the `SessionStart` message names the rescue instead of the file count.

## Retention

`pruneFinished()` in `agent-session-reader.mjs` deletes `sessions/*.json` entries whose
`finishedAt` is non-null and older than `FINISHED_RETENTION_MS` (30 days — matches this box's
other state-directory GC convention, `gc_repo_retention_days` in `remote-build-gc.mjs`). An
entry with `finishedAt: null` (live, idle, detached, or orphaned) is NEVER pruned, regardless
of age. Invoked from every sweep, which already has all rows parsed and is serialized against reap.
Each pass attempts at most `SWEEP_DELETE_LIMIT` (32) expired rows; dead evidence-free birth cleanup
is bounded to the same batch size. Rescue and closure of rows whose recorded process has disappeared
is likewise capped at `SWEEP_LIFECYCLE_LIMIT` (32), while genuinely live processes are refreshed on
every pass. Durable per-class cursors advance after attempted rows,
including locked or malformed ones, so one bad prefix cannot starve later candidates. Cursor state is
best-effort scheduling metadata: a full or read-only ledger filesystem emits a warning but never aborts
process discovery, lifecycle refresh, or the bounded cleanup selected for that pass. A backlog
therefore cannot turn one timer firing into unbounded synchronous unlink/fsync work. Pruning is
deliberately absent from the latency-sensitive `SessionEnd` hook: scanning the directory there
would make leaving one session freeze the desktop when an abnormal backlog exists. Readers
(`readEntries`, the collector) tolerate a file vanishing mid-read (per-file try/catch); pruning during
a concurrent read is safe by construction.

## Notification

Edge-triggered only — canonical doctrine: `~/.claude/projects/-home-user/memory/feedback_notifications_edge_triggered.md`. Notify once per `(ledgerId, state)` transition, recorded in `notified.json`. NEVER re-notify on a timer.

## User-facing vocabulary

Say "reopen" / "attach" / "resume". NEVER print `dtach`, `tmux`, or a raw multiplexer command to the user. `agent-sessions attach <ledgerId>` is the only invocation a user is shown.
