Files
pad/internal/cli/session_owner.go
T
xarmian e747a1610c feat(session): registry keyed on the harness session, carrying the agent name; pad session list / prune (TASK-2767) (#1200)
## Summary

TASK-2767 (IDEA-2750 part 2, with part 3 riding along — the keying fix and the reaping are one mechanism).

The local session registry (`~/.pad/sessions`) was keyed on the pid of the `pad session register` subprocess, which is dead before anyone reads the file. One session left a new file per call and its own pid appeared in none of them; the only live identifier was the harness pid a reader could parse out of the socket path's basename. In practice nothing wrote it (zero callers in `plugin/`, `skills/`, or hooks) and nothing read it.

Now:

- **One record per session, keyed on the harness session pid** — `$PAD_SESSION_PID` (harness-agnostic override), else `$CLAUDE_PID` (verified present in both the tool shell and a live plugin monitor's `/proc/<pid>/environ`), else the calling process. A set-but-invalid value is an error, not a silent fall-through.
- **The record carries the agent name** the session's writes are attributed to (`ResolveAgentName`: `.pad.toml agent_name` → `$PAD_AGENT` → detected runtime; `--agent` overrides, `--agent ""` is anonymous), the harness session id, and the messaging socket's identity (inode/device/mtime — the same binding the arm-state file uses).
- **One owner-identity type, one verdict.** `internal/cli/session_owner.go`: `SessionOwner` + tri-state `OwnerLiveness` (`alive` / `dead` / `unknown`). `armStateOwnerAlive` is now `OwnerLiveness(...) == alive` with its file contract preserved (socket identity else mtime; headless pid + start token; fail closed). The registry pruner takes the opposite posture on `unknown`: on Windows `pidAlive` reports dead for every pid, and a reaper built on that would delete every live session's record.
- **Verbs:** `pad session register [--agent]` (writes/refreshes; prunes dead records), `pad session list [--agent] [--cwd] [--all] [--format json]` (liveness per row, newest first; dead hidden unless `--all`), `pad session prune [--older-than DUR]` (dead always; unknown only under an explicit bound; alive never). Nothing on MCP — host-local filesystem state.
- **Who registers:** `plugin/scripts/pad-monitor.sh` runs `pad session register` on start, BEFORE the consent gate — presence is a fact, consent is a grant, and the record is local/0600/never on the wire.
- **Legacy v1 files** list as `legacy` rows: owner = socket-basename pid (else registrar pid), liveness by pid only (v1 recorded no socket identity, and the socket-without-identity rule would have judged every legacy record dead while its session ran). A legacy row can say a session exists, never who it is.

Lead rulings on the four open decisions, all as built: `agent`/`--agent` vocabulary; no server-presence merge in `list`; register from the monitor script before the gate; wire follow-on (agent name on the stream) filed separately as IDEA-2750 part 2b.

One ordering change from the plan's section A: pid precedence is `PAD_SESSION_PID` > `CLAUDE_PID` > self (explicit override beats detection, mirroring `PAD_AGENT` over runtime detection); the plan listed `CLAUDE_PID` first.

## Behaviour changes for existing users of `~/.pad/sessions` / `pad session register`

- Registry files are keyed on the **harness session pid** (`PAD_SESSION_PID` → `CLAUDE_PID` → self), not the `pad` command's pid; repeated registrations overwrite one record instead of accumulating.
- `pad session register` records the agent name, harness session id and socket identity; stores the **real path** of the cwd; prints a different text line and a different JSON shape (the full `SessionRecord`); and **rejects** an invalid `PAD_SESSION_PID` / `CLAUDE_PID` instead of silently keying on itself.
- Existing v1 files are read as `legacy` rows (owner = socket-basename pid, no agent name) and dead ones are pruned by the next register.
- The plugin monitor now registers (and prunes) on every start, before the consent gate.
- `armStateOwnerAlive` now delegates to the shared `OwnerLiveness`; the consent gate's observable behaviour is unchanged on every platform and key type (codex round 4 traced every caller; matrix M29 pins the socket-keyed mapping).

https://claude.ai/code/session_016zc6oxBvpax6Z3iQMsAJno
2026-08-25 15:31:16 -04:00

250 lines
9.9 KiB
Go

package cli
import (
"errors"
"fmt"
"os"
"runtime"
"strconv"
)
// Probe failures that mean "the owner is GONE" (as opposed to "the owner
// could not be examined"). See pidLiveness.
var (
errProcZombie = errors.New("process is a zombie")
errProcStatMalformed = errors.New("/proc stat is malformed")
)
// Session owner identity (TASK-2767, IDEA-2750 part 2).
//
// Two local files describe "the session this process belongs to": the
// arm-state file (session_arm_state.go) and the session registry
// (session_registry.go). Both must answer the same question — is the
// OWNER of this record still alive, and is it the SAME owner that wrote
// it — and they used to answer it in two places with two different
// vocabularies. SessionOwner is the one identity they share, and
// OwnerLiveness is the one verdict.
//
// WHAT "OWNER" MEANS. The process a short-lived `pad` command was run
// FROM — an agent harness's session process — not the `pad` command
// itself, whose pid is dead before anyone reads the record. The harness
// names it: Claude Code exports CLAUDE_PID; any other harness can export
// PAD_SESSION_PID (the runtime-agnostic override, exactly as PAD_AGENT is
// for the agent name). With neither, the owner is this process — correct
// for a long-lived `pad` process such as a monitor, and the documented
// residual for a bare shell.
//
// WHY LIVENESS IS TRI-STATE. A consent gate and a reaper need opposite
// defaults. A consent gate treats anything uncertain as dead (a stale file
// must never arm a future session — PLAN-2613 constraint 2). A reaper
// treating uncertain as dead would delete every live record on a platform
// where pids cannot be probed (pidAlive reports dead for every pid on
// Windows). So the verdict carries its uncertainty, and each consumer
// applies its own posture: armStateOwnerAlive is `== LivenessAlive`;
// PruneSessions deletes only LivenessDead and leaves LivenessUnknown to
// an explicit age bound.
// Liveness is the verdict on a recorded session owner.
type Liveness string
const (
// LivenessAlive: the recorded owner exists now AND its identity matches
// what was recorded (socket inode/mtime, or pid + process start token).
LivenessAlive Liveness = "alive"
// LivenessDead: the owner is gone, or the thing at its address is a
// different owner (a rebound socket, a reused pid).
LivenessDead Liveness = "dead"
// LivenessUnknown: this platform cannot probe the owner at all. Not
// dead — a reaper must not act on it — and not alive — a gate must not
// trust it.
LivenessUnknown Liveness = "unknown"
)
// SessionOwner identifies the session a record belongs to, with enough
// identity to tell a reused address from the original. JSON tags are the
// session registry's on-disk keys (messaging_socket_path is the v1 key,
// kept so legacy files still parse).
type SessionOwner struct {
// PID is the owner process. Zero in a legacy registry file.
PID int `json:"session_pid,omitempty"`
// PIDSource records where PID came from: "PAD_SESSION_PID", "CLAUDE_PID",
// or "self" (os.Getpid()). Legacy records derive one — see
// legacyOwner. Recorded because a self-keyed record from a short-lived
// command is dead by the time anyone reads it, and a reader should be
// able to see that this is why.
PIDSource string `json:"session_pid_source,omitempty"`
// PIDVerified is true when the claimed pid was checked against this
// process's ancestry at capture time (pidIsSelfOrAncestor — Linux) or
// is this process itself. A harness-supplied pid is otherwise a bare
// claim: any positive integer names SOME process, and the start token
// only guards against later reuse, not against a wrong pid in the
// first place (codex round 3). False on platforms without an ancestry
// walk, and for legacy rows.
PIDVerified bool `json:"session_pid_verified,omitempty"`
// ProcStart is the owner's process start token (procStartToken) when
// the platform supplies one — the pid-reuse defence. Empty elsewhere.
ProcStart string `json:"proc_start,omitempty"`
// Socket is CLAUDE_CODE_MESSAGING_SOCKET at capture time, recorded only
// when the socket existed then (its identity below is what makes it a
// liveness signal; a path alone proves nothing).
Socket string `json:"messaging_socket_path,omitempty"`
// SocketMtimeUnixNano, SocketIno, SocketDev bind the record to the
// specific socket INSTANCE — the same identity the arm-state file uses
// (Codex R1 HIGH-2 / R2 finding 2 on PLAN-2613 S2): a socket rebound at
// the same path is a different session.
SocketMtimeUnixNano int64 `json:"socket_mtime_unix_nano,omitempty"`
SocketIno uint64 `json:"socket_ino,omitempty"`
SocketDev uint64 `json:"socket_dev,omitempty"`
}
// CaptureSessionOwner reads this process's session owner from the
// environment. Precedence for the pid, most explicit first:
//
// 1. $PAD_SESSION_PID — the harness-agnostic override.
// 2. $CLAUDE_PID — Claude Code's own export (verified against a live
// session: present in the tool shell AND in the plugin monitor's
// environment).
// 3. os.Getpid() — this process.
//
// A set-but-invalid value is an error, not a fall-through: a harness that
// exports garbage would otherwise key every record on a dead command pid
// while reporting success, which is the silent misregistration this whole
// unit exists to end.
func CaptureSessionOwner() (SessionOwner, error) {
o := SessionOwner{PID: os.Getpid(), PIDSource: "self"}
for _, env := range []string{"PAD_SESSION_PID", "CLAUDE_PID"} {
v := os.Getenv(env)
if v == "" {
continue
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return SessionOwner{}, fmt.Errorf("%s=%q is not a positive integer", env, v)
}
o.PID, o.PIDSource = n, env
break
}
if o.PIDSource == "self" {
o.PIDVerified = true
} else {
// A wrong-but-live pid must not be recorded as if it were checked;
// an unreadable /proc leaves it unverified too.
o.PIDVerified, _ = pidIsSelfOrAncestor(o.PID)
}
// Best effort: empty where the platform has no token, and empty when
// the pid cannot be read (a harness pid we cannot see is recorded
// without a token and judged by bare liveness, the documented residual).
o.ProcStart, _ = procStartToken(o.PID)
if sock := os.Getenv("CLAUDE_CODE_MESSAGING_SOCKET"); sock != "" {
if info, err := os.Stat(sock); err == nil {
o.Socket = sock
o.SocketMtimeUnixNano = info.ModTime().UnixNano()
if ino, dev, ok := statIdentity(info); ok {
o.SocketIno, o.SocketDev = ino, dev
}
}
}
return o, nil
}
// OwnerLiveness is the shared verdict. EVERY recorded signal must agree:
// the socket (when recorded) must still exist with the identity captured
// at registration — inode/device, else mtime, so a reused path is a
// different session — AND the pid (when recorded) must be alive and, when
// a start token was recorded, still carry it. Either signal dead → dead.
// Neither alone suffices for the registry: a socket node outlives a
// SIGKILLed owner (the kernel does not unlink it), so a socket-only verdict
// would report a crashed harness alive indefinitely (codex round 1 P1);
// and a pid alone cannot exclude reuse on platforms without a start token.
//
// Unknown is the third answer, and it is reserved for "could not examine",
// never "not sure it is alive": a platform that cannot probe pids
// (Windows), a socket the caller cannot stat (a permission or I/O error,
// as opposed to ENOENT), or a /proc entry that cannot be read (hidepid, a
// namespace boundary). A dead verdict is only ever issued on positive
// evidence of absence or of a different owner (P2). The two consumers
// then choose: the consent gate treats unknown as not-armed, the pruner
// leaves it alone.
//
// A record with NO signal at all (no socket, no pid) is dead.
func OwnerLiveness(o *SessionOwner) Liveness {
if o == nil || (o.Socket == "" && o.PID <= 0) {
return LivenessDead
}
verdict := LivenessAlive
if o.Socket != "" {
switch socketLiveness(o) {
case LivenessDead:
return LivenessDead
case LivenessUnknown:
verdict = LivenessUnknown
}
}
if o.PID > 0 {
switch pidLiveness(o) {
case LivenessDead:
return LivenessDead
case LivenessUnknown:
verdict = LivenessUnknown
}
}
return verdict
}
// socketLiveness judges the recorded socket instance.
func socketLiveness(o *SessionOwner) Liveness {
info, err := os.Stat(o.Socket)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return LivenessDead // socket vanished — session gone
}
return LivenessUnknown // could not examine (EACCES, EIO, ...)
}
if o.SocketMtimeUnixNano == 0 {
return LivenessDead // no identity recorded — cannot prove it is ours
}
if o.SocketIno != 0 {
if ino, dev, ok := statIdentity(info); ok {
if ino == o.SocketIno && dev == o.SocketDev {
return LivenessAlive
}
return LivenessDead // rebound at the same path — a different session
}
}
if info.ModTime().UnixNano() == o.SocketMtimeUnixNano {
return LivenessAlive
}
return LivenessDead
}
// pidLiveness judges the recorded owner pid. A live pid with no token
// recorded is alive on bare liveness — the documented residual for records
// written where no start token exists (non-Linux) and for legacy rows,
// which never had one; pid reuse cannot be excluded there.
func pidLiveness(o *SessionOwner) Liveness {
if runtime.GOOS == "windows" {
// pidAlive cannot probe here (Signal(0) is unsupported), so a
// verdict either way would be invented.
return LivenessUnknown
}
if !pidAlive(o.PID) {
return LivenessDead
}
if o.ProcStart == "" {
return LivenessAlive
}
now, err := procStartTokenErr(o.PID)
switch {
case err == nil:
if now == o.ProcStart {
return LivenessAlive
}
return LivenessDead // a reused pid — a different process
case errors.Is(err, os.ErrNotExist), errors.Is(err, errProcZombie):
return LivenessDead // gone between the signal probe and the read, or exited awaiting reap
default:
return LivenessUnknown // /proc unreadable or unsupported here — not evidence of absence
}
}