# 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.

## 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.

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,
  "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 a tmux server has accepted the session — by `_agent-session-tmux` for a dedicated server, by `_human-session` for the shared one — or derived by discovery 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 (20s).
- `finishedAt` non-null ⇒ ended. Its absence is what makes a dead session ORPHANED. Only `claude` has an end-of-session hook; for `codex` / `cursor-agent` the sweep closes the entry with `finishReason: "process exited"` once the pid is gone. 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"`) — a row written by a launcher that has not exec'd yet has the same empty shape, and closing it would lose a starting session and re-enrol it under a second id — a short headless run that exits before either is known has no work to lose, and keeping it open would add one permanent LOST row per dispatch. Consequence, stated rather than hidden: those runtimes NEVER report ORPHANED, because a crash and a clean exit are indistinguishable from `/proc`. Without this an entry for every codex run ever started would stay in the panel forever.

- `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. `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 and from the `SessionEnd` hook — no notification. The hook
alone is not enough: it fires only for `claude`, so a `codex` or `cursor-agent` row, and any
row the sweep closes itself, would never reach a pruning code path. Consequence: an entry
older than the window disappears from the panel for every runtime, not just `claude`.
Readers (`readEntries`, the collector) already 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.
