mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
07215d4953
* fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970)
Dave: "the plugin should not be doing anything when a session is started."
session.start now only registers /pad-pane and restores the pane. It no
longer runs `pad session register` / `should-arm`, no longer spawns
pad-monitor.sh, and no longer starts the 5s refresh timer, which now runs
only while the pane is open.
wake() runs once per session, on the first pad Bash command, a Pad MCP tool
call, or opening the pane. The first pad command waits for registration
(never for should-arm), because register mints the worker id that leases key
on. A .pad.toml push.auto_arm opt-in is therefore read at first use.
Tests: session start runs no process and asks for no timer; non-pad Bash
wakes nothing; wake runs once; an MCP tool wakes it; registration happens
before the first pad command runs. Prose swept (skill, monitor script,
CLAUDE.md, registry comments); docs/plugin-releases.md gets the stuck-0.3.x
remedy.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): a pad command inside a shell string or after an assignment wakes the session (TASK-3970)
Hunter r1 F1: `bash -c "pad ..."`, `sh -c 'pad ...'` and `X=... pad` missed
the first-use match, so that call ran before presence was recorded. The
class now takes quotes and `=`; a false positive only wakes early.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): pad as a whole word, checked against a table of shapes (TASK-3970)
Round r2 found two more missed shapes (Codex: `\pad ...`; hunter: a bare
`pad` before a quote, `)` or `;`). Rather than add a third case, the match
is now `pad` as a whole word, not inside a longer name. watch.test.ts
tables 25 shapes that run pad and 8 that do not, and asserts that
registration ran before each matching command reached the tool
(Codex r2 note).
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): a Pad MCP tool wakes the session whatever the server is called (TASK-3970)
Codex r3: the matcher required "pad" in the server part of the tool name,
so a Pad server registered under another name (work-tracker) was missed.
It now matches any mcp__<server>__pad_* tool. The tests table Pad tools
under four server names and four tools that are not Pad's, and add ./pad
and ../bin/pad to the Bash shapes.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): the pane refreshes after a Pad MCP edit under any server name (TASK-3970)
Hunter r4: the pane-refresh matcher still required "pad" in the server
name, so it disagreed with the wake matcher fixed in 25c39f2aa. Both now
take any server. The wake matcher also allows digits in the tool part.
New pane test.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* docs(plugin): the file header describes session start and wake (TASK-3970)
Codex r5 nit: the header still said the mod only listens until /pad-pane
runs. Comment-only.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
581 lines
24 KiB
Go
581 lines
24 KiB
Go
package cli
|
|
|
|
// On-disk session registry (TASK-2533; reworked in TASK-2767 for IDEA-2750
|
|
// part 2).
|
|
//
|
|
// ~/.pad/sessions/<session_pid>.json — one record per SESSION, keyed on
|
|
// the OWNER pid (the agent harness's session process, see SessionOwner),
|
|
// carrying the agent name the session declared. `pad session register`
|
|
// writes it; `pad session list` reads it with a liveness verdict per row;
|
|
// `pad session prune` removes the ones it can see are dead. The plugin registers
|
|
// a Claude Code session on its first use of Pad (a pad command, a Pad MCP
|
|
// tool, the pane), never at session start (TASK-3970), so a session that
|
|
// never touches Pad has no record; any other harness calls register from its
|
|
// own hook with PAD_SESSION_PID / PAD_AGENT set.
|
|
//
|
|
// WHAT IT ANSWERS. "Which of my sessions on this machine are alive right
|
|
// now, and which agent is each one running as?" — a local, deterministic
|
|
// read that needs no server and no guessing. It is NOT the server's
|
|
// presence registry (`pad session status` / GET /api/v1/sessions): that
|
|
// surface exists only while an armed monitor holds a stream, refreshes on
|
|
// its own clock, and knows nothing about agent names. The two are kept
|
|
// separate on purpose — blending two sources with different failure
|
|
// modes would produce a list that is wrong in ways neither is alone.
|
|
//
|
|
// WHY THE KEY CHANGED. v1 keyed the file on os.Getpid() — the pid of the
|
|
// `pad session register` subprocess, which is dead before anyone reads
|
|
// the file. One session produced a new file per call and its own pid
|
|
// appeared in none of them; the only live identifier was the pid a
|
|
// reader could parse out of messaging_socket_path's basename. v2 keys on
|
|
// the harness-provided owner pid and records the socket's identity, so a
|
|
// re-register overwrites (a refresh) and liveness is a stat, not a parse.
|
|
// Legacy v1 files (no session_pid) still list — see legacyOwner — and
|
|
// prune like any other record.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// SessionRegistration is one session's on-disk record.
|
|
type SessionRegistration struct {
|
|
// SessionOwner supplies session_pid, session_pid_source, proc_start,
|
|
// messaging_socket_path and the socket identity fields.
|
|
SessionOwner
|
|
// SessionID is the harness's own session identifier when it exports
|
|
// one — $PAD_SESSION_ID first (the harness-agnostic override, on the
|
|
// same footing as PAD_SESSION_PID and PAD_AGENT), else Claude Code's
|
|
// $CLAUDE_CODE_SESSION_ID — a handle for tooling that addresses
|
|
// sessions by id (transcript paths, session measurement). Opaque to
|
|
// pad.
|
|
SessionID string `json:"session_id,omitempty"`
|
|
// Agent is the name the session declared for itself, recorded so a
|
|
// reader can tell two sessions in one checkout apart by what they are
|
|
// working AS. When non-empty it is ALSO the value ResolveAgentName
|
|
// stamps on every write from this session — by construction since
|
|
// BUG-2882, which found the two disagreeing on both live seats: the
|
|
// resolver now reads this record first (registeredAgentForThisSession).
|
|
// Empty is an anonymous session; it does not blank an environment-
|
|
// declared name on writes.
|
|
Agent string `json:"agent,omitempty"`
|
|
// WorkerID is this session's lease worker id (TASK-3613): "wk_" + 32
|
|
// hex, minted once per session process and sent as X-Pad-Worker, so two
|
|
// sessions under one name and account hold distinct leases. Kept when
|
|
// the same process re-registers (a /clear), new for a new process.
|
|
WorkerID string `json:"worker_id,omitempty"`
|
|
// RegistrarPID is the pid of the process that wrote the file (the
|
|
// `pad` command). Its JSON key is v1's "pid", kept for compatibility;
|
|
// it is informational — the OWNER is SessionOwner.PID.
|
|
RegistrarPID int `json:"pid"`
|
|
// Cwd is the directory the session was registered from.
|
|
Cwd string `json:"cwd"`
|
|
// RegisteredAt is RFC3339 UTC.
|
|
RegisteredAt string `json:"registered_at"`
|
|
}
|
|
|
|
// SessionRecord is one registry entry as `pad session list` reports it:
|
|
// the registration plus the reader's verdict on it. Its JSON shape is the
|
|
// stable contract consumers script against.
|
|
type SessionRecord struct {
|
|
// SessionPID is the owner pid — session_pid for a v2 record, derived
|
|
// for a legacy one (see Legacy).
|
|
SessionPID int `json:"session_pid"`
|
|
// SessionPIDSource is where SessionPID came from: "PAD_SESSION_PID",
|
|
// "CLAUDE_PID", "self", or for legacy files "legacy-socket" (parsed
|
|
// from the socket basename) / "legacy-registrar" (the v1 pid field).
|
|
SessionPIDSource string `json:"session_pid_source,omitempty"`
|
|
// SessionPIDVerified is true when the recorded pid was checked against
|
|
// the registering process's ancestry (Linux) or is that process. Always
|
|
// present in JSON so a consumer that requires a verified claim can see
|
|
// an explicit false. A legacy row is never verified.
|
|
SessionPIDVerified bool `json:"session_pid_verified"`
|
|
// Agent is the declared agent name; always present in JSON, "" when
|
|
// anonymous or legacy.
|
|
Agent string `json:"agent"`
|
|
Cwd string `json:"cwd"`
|
|
// SessionID is the harness session id, when recorded.
|
|
SessionID string `json:"session_id,omitempty"`
|
|
// Liveness is OwnerLiveness's verdict at list time.
|
|
Liveness Liveness `json:"liveness"`
|
|
// RegisteredAt is the record's own timestamp, or the file's mtime when
|
|
// the record is malformed.
|
|
RegisteredAt string `json:"registered_at"`
|
|
// Legacy marks a v1 file: keyed on a dead command pid, with no agent
|
|
// name and no socket identity. Its liveness is judged by the derived
|
|
// owner pid alone, so it can say a session EXISTS but never WHO it is.
|
|
// Identity-sensitive consumers should treat a legacy row as
|
|
// indeterminate, not as an anonymous session.
|
|
Legacy bool `json:"legacy,omitempty"`
|
|
// Malformed marks a file that is not parseable as a registration. It
|
|
// is listed rather than hidden (a hidden file is one nobody prunes),
|
|
// with Liveness unknown, so only an explicit age bound removes it.
|
|
Malformed bool `json:"malformed,omitempty"`
|
|
// MessagingSocketPath is the recorded socket path, for humans.
|
|
MessagingSocketPath string `json:"messaging_socket_path,omitempty"`
|
|
// Path is the record's file.
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// SessionsDir returns ~/.pad/sessions, creating it if it doesn't exist.
|
|
func SessionsDir() (string, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("get home directory: %w", err)
|
|
}
|
|
dir := filepath.Join(homeDir, ".pad", "sessions")
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return "", fmt.Errorf("create sessions directory: %w", err)
|
|
}
|
|
// MkdirAll applies the mode only on creation; a directory that already
|
|
// existed with a looser mode keeps it. The registry's records are
|
|
// 0600 each, but a permissive directory would let another local user
|
|
// see the filenames (pids) and plant files — tighten it every time
|
|
// (codex round 3). Best effort: the caller's own directory.
|
|
if info, err := os.Stat(dir); err == nil && info.Mode().Perm() != 0700 {
|
|
_ = os.Chmod(dir, 0700)
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
// registeredAgentForThisSession reads the registry record for the session
|
|
// that owns this process — the same owner CaptureSessionOwner would record —
|
|
// and returns its agent name. It is the first thing ResolveAgentName consults
|
|
// (BUG-2882), so it must be cheap and must never create anything: a plain
|
|
// stat-and-read of one file, no MkdirAll, no lock (a torn read of a record
|
|
// being rewritten parses as malformed and is ignored, and the next call sees
|
|
// the new record).
|
|
//
|
|
// ok is false — FAIL CLOSED — unless the record is confirmed to be THIS
|
|
// session's (codex round 1 on #1248): it must parse, carry this owner pid,
|
|
// pass the same OwnerLiveness verdict `pad session list` applies (socket
|
|
// identity and pid alive with its token), and, when this process can read a
|
|
// process-start token for the owner, carry the SAME token. A record with no
|
|
// token while we have one is unverifiable, not verified: a dead session's
|
|
// stale row under a reused pid must not name a live one. Where the platform
|
|
// has no token at all, bare liveness is the documented residual, as it is for
|
|
// the registry itself. And where the platform can walk process ancestry, the
|
|
// owner pid must be this process or an ancestor — the same claim
|
|
// session_pid_verified reports — so a misconfigured CLAUDE_PID cannot borrow
|
|
// another live session's name. A record with an empty agent is a deliberate anonymous
|
|
// registration and returns ("", true); the caller decides that this does not
|
|
// override an environment-declared name.
|
|
func registeredAgentForThisSession() (string, bool) {
|
|
_, reg, ok := thisSessionRegistration()
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return reg.Agent, true
|
|
}
|
|
|
|
// thisSessionRegistration is the live registry record of the session this
|
|
// process belongs to, with the path it was read from, under every check
|
|
// registeredAgentForThisSession documents above; ok=false when there is none
|
|
// we can trust.
|
|
func thisSessionRegistration() (string, SessionRegistration, bool) {
|
|
var none SessionRegistration
|
|
owner, err := CaptureSessionOwner()
|
|
if err != nil {
|
|
return "", none, false
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", none, false
|
|
}
|
|
path := filepath.Join(home, ".pad", "sessions", fmt.Sprintf("%d.json", owner.PID))
|
|
data, err := readRegistryBytes(path)
|
|
if err != nil {
|
|
return "", none, false
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return "", none, false
|
|
}
|
|
var reg SessionRegistration
|
|
if err := json.Unmarshal(data, ®); err != nil || !registrationWellFormed(®) {
|
|
return "", none, false
|
|
}
|
|
if reg.PID != owner.PID {
|
|
// v1 (legacy) records have no owner pid; they never carried a name.
|
|
return "", none, false
|
|
}
|
|
if owner.ProcStart != "" && reg.ProcStart != owner.ProcStart {
|
|
// Includes the empty case: a token we can check and the record
|
|
// cannot supply is a record we cannot trust.
|
|
return "", none, false
|
|
}
|
|
if OwnerLiveness(®.SessionOwner) != LivenessAlive {
|
|
return "", none, false
|
|
}
|
|
// The owner must be THIS process or an ancestor, where the platform can
|
|
// say (codex round 2): a harness-supplied pid that is alive, token-matched
|
|
// and yet not above us is a misconfigured session, and its row is not
|
|
// ours to speak for. CaptureSessionOwner records that outcome as
|
|
// PIDVerified=false but does not distinguish "checked and wrong" from
|
|
// "cannot check"; gating on the flag would disable this step on every
|
|
// platform without ancestry (see proc_ancestry_other.go), so the check is
|
|
// re-run here and only a checked-and-wrong answer refuses.
|
|
if ok, err := pidIsSelfOrAncestor(owner.PID); err == nil && !ok {
|
|
return "", none, false
|
|
}
|
|
return path, reg, true
|
|
}
|
|
|
|
// registryFileName matches registry records — and ONLY them. The same
|
|
// directory holds arm-state files (arm-*.json), atomic-write temp files
|
|
// (.arm-*.tmp) and the mutation lock (.lock), which are not sessions.
|
|
var registryFileName = regexp.MustCompile(`^[0-9]+\.json$`)
|
|
|
|
// sessionsLockName is the advisory lock file register and prune hold while
|
|
// mutating the directory (see lockSessionsDir).
|
|
const sessionsLockName = ".lock"
|
|
|
|
// RegisterSession records the CURRENT session — owner from the
|
|
// environment (CaptureSessionOwner), the given cwd and agent name — to
|
|
// ~/.pad/sessions/<session_pid>.json, mode 0600, atomically. Re-registering
|
|
// the same session overwrites the record (a refresh). It also prunes DEAD
|
|
// records on the way, best effort (the keying fix and the reaping are one
|
|
// mechanism: a registry that overwrites instead of accumulating still
|
|
// needs someone to remove the records of sessions that ended).
|
|
func RegisterSession(cwd, agent string) (SessionRecord, error) {
|
|
dir, err := SessionsDir()
|
|
if err != nil {
|
|
return SessionRecord{}, err
|
|
}
|
|
owner, err := CaptureSessionOwner()
|
|
if err != nil {
|
|
return SessionRecord{}, err
|
|
}
|
|
reg := SessionRegistration{
|
|
SessionOwner: owner,
|
|
SessionID: firstEnv("PAD_SESSION_ID", "CLAUDE_CODE_SESSION_ID"),
|
|
Agent: agent,
|
|
RegistrarPID: os.Getpid(),
|
|
Cwd: cwd,
|
|
// Nanosecond precision so two registrations in one second still
|
|
// order (codex round 2): "newest first" must not fall back to
|
|
// filename order. v1 wrote whole seconds; both parse as RFC3339.
|
|
RegisteredAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
}
|
|
unlock, err := lockSessionsDir(dir)
|
|
if err != nil {
|
|
return SessionRecord{}, fmt.Errorf("lock sessions directory: %w", err)
|
|
}
|
|
defer unlock()
|
|
path := filepath.Join(dir, fmt.Sprintf("%d.json", reg.PID))
|
|
// The worker id survives a re-registration by the SAME process (same
|
|
// pid and start token: a /clear re-runs register) and is new for a new
|
|
// process, even one that reuses the pid (TASK-3613). Read under the lock,
|
|
// so two registrations cannot mint two ids.
|
|
reg.WorkerID = keptWorkerID(path, owner)
|
|
if reg.WorkerID == "" {
|
|
if reg.WorkerID, err = MintWorkerID(); err != nil {
|
|
return SessionRecord{}, err
|
|
}
|
|
}
|
|
data, err := json.MarshalIndent(reg, "", " ")
|
|
if err != nil {
|
|
return SessionRecord{}, fmt.Errorf("marshal session registration: %w", err)
|
|
}
|
|
if err := atomicWriteFile(path, data, 0600); err != nil {
|
|
return SessionRecord{}, fmt.Errorf("write session registration: %w", err)
|
|
}
|
|
// Best effort, and only DEAD records: unknown ones need an explicit
|
|
// age bound (PruneSessions), which register has no business choosing.
|
|
// Under the same lock as the write, so no other mutator interleaves.
|
|
_, _ = pruneLocked(0, time.Now())
|
|
return recordFor(path, reg), nil
|
|
}
|
|
|
|
func firstEnv(names ...string) string {
|
|
for _, n := range names {
|
|
if v := os.Getenv(n); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ListSessions reads every registry record and returns them with a
|
|
// liveness verdict each, newest first. A malformed file is returned as a
|
|
// Malformed record rather than an error, so one bad file does not hide
|
|
// the rest — and so it can be pruned.
|
|
func ListSessions() ([]SessionRecord, error) {
|
|
dir, err := SessionsDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read sessions directory: %w", err)
|
|
}
|
|
var out []SessionRecord
|
|
for _, e := range entries {
|
|
// Regular files only: a symlink under a session-shaped name would
|
|
// read some other file as a record (duplicating a live row, or
|
|
// injecting one), and a directory is not a file at all.
|
|
if !e.Type().IsRegular() || !registryFileName.MatchString(e.Name()) {
|
|
continue
|
|
}
|
|
path := filepath.Join(dir, e.Name())
|
|
rec, err := readSessionRecord(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue // removed between ReadDir and read — a concurrent prune
|
|
}
|
|
return nil, err
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
// Compare as TIMES, not strings: v1 whole-second and v2 nanosecond
|
|
// stamps interleave, and "…:08Z" vs "…:08.5Z" would string-sort the
|
|
// later one first. Ties (identical instants) and unparseable
|
|
// stamps fall to path order, which at least is deterministic.
|
|
ti, ei := time.Parse(time.RFC3339, out[i].RegisteredAt)
|
|
tj, ej := time.Parse(time.RFC3339, out[j].RegisteredAt)
|
|
if ei == nil && ej == nil && !ti.Equal(tj) {
|
|
return ti.After(tj)
|
|
}
|
|
if (ei == nil) != (ej == nil) {
|
|
return ei == nil // parseable before unparseable
|
|
}
|
|
return out[i].Path < out[j].Path
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// readSessionRecord reads one file and judges it. Only a missing file is
|
|
// an error; unreadable or unparseable content is a Malformed record.
|
|
func readSessionRecord(path string) (SessionRecord, error) {
|
|
data, err := readRegistryBytes(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return SessionRecord{}, err
|
|
}
|
|
return malformedRecord(path), nil
|
|
}
|
|
var reg SessionRegistration
|
|
// Invalid UTF-8 is rejected outright: encoding/json would otherwise
|
|
// replace the bad bytes with U+FFFD and hand back an agent name or cwd
|
|
// that is not what the file says (codex round 8).
|
|
if !utf8.Valid(data) {
|
|
return malformedRecord(path), nil
|
|
}
|
|
if err := json.Unmarshal(data, ®); err != nil || !registrationWellFormed(®) {
|
|
return malformedRecord(path), nil
|
|
}
|
|
return recordFor(path, reg), nil
|
|
}
|
|
|
|
// maxRegistryRecordBytes bounds what a registry read will load. A real
|
|
// record is well under 1 KiB (a handful of short fields); the bound is
|
|
// generous so no legitimate writer approaches it, and it turns a
|
|
// multi-megabyte file under a session-shaped name into a malformed row
|
|
// instead of a memory bill at every monitor start (codex round 8).
|
|
const maxRegistryRecordBytes = 64 * 1024
|
|
|
|
var (
|
|
errRegistryNotRegular = errors.New("registry entry is not a regular file")
|
|
errRegistryTooLarge = errors.New("registry entry exceeds the record size bound")
|
|
)
|
|
|
|
// readRegistryBytes opens a registry entry WITHOUT following symlinks or
|
|
// blocking on a FIFO (where the platform allows), re-checks that what it
|
|
// opened is a regular file — ListSessions filtered on the directory
|
|
// entry's type, but a same-user process could swap the name between that
|
|
// check and this open — and reads at most the record bound.
|
|
func readRegistryBytes(path string) ([]byte, error) {
|
|
f, err := openRegistryFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, errRegistryNotRegular
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(f, maxRegistryRecordBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > maxRegistryRecordBytes {
|
|
return nil, errRegistryTooLarge
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// registrationWellFormed rejects files that parse as JSON but are not
|
|
// something this code wrote (codex round 1 P2): `{}` would otherwise read
|
|
// as a dead legacy record (owner pid 0) and be pruned with no age bound,
|
|
// and `{"session_pid":1}` as a live one. Every record carries a cwd, a
|
|
// positive registrar pid and an RFC3339 registered_at; session_pid is
|
|
// positive for a v2 record and absent for a v1 one — and since JSON
|
|
// cannot tell an absent zero from an explicit one, an explicit
|
|
// session_pid:0 is accepted and read as legacy by recordFor (which then
|
|
// strips the name such a file cannot legitimately carry). A negative
|
|
// session_pid, or a missing field, is malformed — listed, unknown, and
|
|
// removed only under an explicit age bound.
|
|
func registrationWellFormed(reg *SessionRegistration) bool {
|
|
// Fields every writer (v1 and v2) always emitted: a cwd, a positive
|
|
// registrar pid, an RFC3339 timestamp.
|
|
if reg.RegisteredAt == "" || reg.Cwd == "" || reg.RegistrarPID <= 0 {
|
|
return false
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, reg.RegisteredAt); err != nil {
|
|
return false
|
|
}
|
|
// v2 has a positive owner pid; v1 has none (zero). Negative is a file
|
|
// nobody wrote.
|
|
return reg.PID >= 0
|
|
}
|
|
|
|
// malformedRecord describes a file this code cannot read as a
|
|
// registration: liveness unknown (nothing to probe), timestamp from the
|
|
// file's mtime so an age bound can still reason about it.
|
|
func malformedRecord(path string) SessionRecord {
|
|
rec := SessionRecord{Path: path, Liveness: LivenessUnknown, Malformed: true}
|
|
if info, err := os.Stat(path); err == nil {
|
|
rec.RegisteredAt = info.ModTime().UTC().Format(time.RFC3339)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// recordFor projects a registration into a record, computing liveness.
|
|
func recordFor(path string, reg SessionRegistration) SessionRecord {
|
|
rec := SessionRecord{
|
|
SessionPID: reg.PID,
|
|
SessionPIDSource: reg.PIDSource,
|
|
SessionPIDVerified: reg.PIDVerified,
|
|
Agent: reg.Agent,
|
|
Cwd: reg.Cwd,
|
|
SessionID: reg.SessionID,
|
|
RegisteredAt: reg.RegisteredAt,
|
|
MessagingSocketPath: reg.Socket,
|
|
Path: path,
|
|
}
|
|
owner := reg.SessionOwner
|
|
if reg.PID == 0 {
|
|
// A v1 file — or a crafted file with an explicit session_pid of 0,
|
|
// which JSON cannot tell from an omitted one. Either way the row
|
|
// is legacy: it carries no verifiable owner, so it carries no
|
|
// agent name and no session id either (codex round 3 — a legacy
|
|
// row must never present a name it cannot have recorded).
|
|
rec.Legacy = true
|
|
rec.Agent, rec.SessionID, rec.SessionPIDVerified = "", "", false
|
|
owner = legacyOwner(reg)
|
|
rec.SessionPID, rec.SessionPIDSource = owner.PID, owner.PIDSource
|
|
}
|
|
rec.Liveness = OwnerLiveness(&owner)
|
|
return rec
|
|
}
|
|
|
|
// legacyOwner derives an owner for a v1 file, which recorded the dead
|
|
// command pid and a bare socket path. The socket basename is the harness
|
|
// pid (Claude Code names its socket <pid>.sock — the read the boot ritual
|
|
// used to do by hand), so that pid is the owner when it parses; otherwise
|
|
// the registrar pid is all there is. Either way the socket is NOT used as
|
|
// the liveness signal: v1 recorded no identity for it, and the
|
|
// socket-without-identity rule would judge every legacy record dead — a
|
|
// live session's stale v1 files would then be reaped while the session
|
|
// runs, which is fine for the files but reads as "no such session" to a
|
|
// consumer listing by liveness. Pid-only is the honest verdict: it can
|
|
// say the session exists, and the Legacy flag says it cannot say who.
|
|
func legacyOwner(reg SessionRegistration) SessionOwner {
|
|
if reg.Socket != "" {
|
|
base := strings.TrimSuffix(filepath.Base(reg.Socket), filepath.Ext(reg.Socket))
|
|
if n, err := strconv.Atoi(base); err == nil && n > 0 {
|
|
return SessionOwner{PID: n, PIDSource: "legacy-socket"}
|
|
}
|
|
}
|
|
return SessionOwner{PID: reg.RegistrarPID, PIDSource: "legacy-registrar"}
|
|
}
|
|
|
|
// PruneReport is what PruneSessions did.
|
|
type PruneReport struct {
|
|
// Removed lists the records deleted, in list order.
|
|
Removed []SessionRecord `json:"removed"`
|
|
// DeadRemoved / UnknownRemoved partition Removed by why.
|
|
DeadRemoved int `json:"dead_removed"`
|
|
UnknownRemoved int `json:"unknown_removed"`
|
|
// Kept counts records left in place (alive, plus unknown ones not
|
|
// covered by the age bound).
|
|
Kept int `json:"kept"`
|
|
}
|
|
|
|
// PruneSessions removes DEAD records, and — only when olderThan > 0 —
|
|
// UNKNOWN records whose timestamp is older than now-olderThan. It never
|
|
// removes an ALIVE record. The age bound is the only way a registry on a
|
|
// platform that cannot probe pids ever shrinks, and it is deliberately an
|
|
// explicit choice: register passes 0.
|
|
//
|
|
// Mutations are serialized under the directory lock (lockSessionsDir), so
|
|
// a register cannot rename a fresh record into place between a prune's
|
|
// read and its remove; each candidate is additionally RE-READ immediately
|
|
// before removal and kept if alive by then (the same non-destructive reap
|
|
// as reapArmFile) — which is what stands alone on platforms without the
|
|
// lock.
|
|
func PruneSessions(olderThan time.Duration, now time.Time) (PruneReport, error) {
|
|
dir, err := SessionsDir()
|
|
if err != nil {
|
|
return PruneReport{}, err
|
|
}
|
|
unlock, err := lockSessionsDir(dir)
|
|
if err != nil {
|
|
return PruneReport{}, fmt.Errorf("lock sessions directory: %w", err)
|
|
}
|
|
defer unlock()
|
|
return pruneLocked(olderThan, now)
|
|
}
|
|
|
|
// pruneLocked is PruneSessions's body; the caller holds the directory lock.
|
|
func pruneLocked(olderThan time.Duration, now time.Time) (PruneReport, error) {
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
return PruneReport{}, err
|
|
}
|
|
var rep PruneReport
|
|
for _, rec := range records {
|
|
remove := false
|
|
switch rec.Liveness {
|
|
case LivenessDead:
|
|
remove = true
|
|
case LivenessUnknown:
|
|
if olderThan > 0 {
|
|
if t, err := time.Parse(time.RFC3339, rec.RegisteredAt); err == nil && now.Sub(t) > olderThan {
|
|
remove = true
|
|
}
|
|
}
|
|
}
|
|
if !remove {
|
|
rep.Kept++
|
|
continue
|
|
}
|
|
if fresh, err := readSessionRecord(rec.Path); err == nil && fresh.Liveness == LivenessAlive {
|
|
rep.Kept++
|
|
continue // re-registered under us — leave it
|
|
}
|
|
if err := os.Remove(rec.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return rep, fmt.Errorf("remove %s: %w", rec.Path, err)
|
|
}
|
|
rep.Removed = append(rep.Removed, rec)
|
|
if rec.Liveness == LivenessDead {
|
|
rep.DeadRemoved++
|
|
} else {
|
|
rep.UnknownRemoved++
|
|
}
|
|
}
|
|
return rep, nil
|
|
}
|