# proclive: add process-liveness package

`cdb6da3`·
  
Soph·3w ago·8 files·+565 added/-0 removed

New leaf package that captures a process's identity (PID + start-time  
fingerprint, plus host/boot guards) and reports whether that exact  
process is still alive. ResolveOwner walks up the process tree to the  
first non-shell, non-entire ancestor (the agent that spawned our hook),  
skipping the Go toolchain too so local-dev's `go run` wrapper isn't  
mistaken for the owner. Check returns Alive/Dead/Unknown — Dead on a  
missing PID, start-time mismatch (PID reuse), or reboot, and Unknown  
when it can't confirm the host/boot or the platform can't introspect  
(Windows), so callers fail closed to a timeout rather than trusting a  
stale PID.

Stdlib + golang.org/x/sys/unix only, so session/strategy/cli can import  
it without an import cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## Sessions

94d3b693e7eeView transcript

## Changes

8

- cmd/entire/cli/proclive

```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

//go:build darwin

package proclive

import (
	"fmt"
	"strconv"

"golang.org/x/sys/unix"
)

// procStat looks up a process via sysctl(kern.proc.pid) and returns its parent
// PID, executable name (comm), and start time as the fingerprint. It uses the
// typed KinfoProc decoder from golang.org/x/sys/unix rather than hand-decoding
// raw sysctl bytes. p_starttime is an absolute wall-clock timeval, so it is a
// stable per-process fingerprint without needing the boot guard.
func procStat(pid int) (ppid int, name, start string, err error) {
	k, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
	if err != nil {
		// A missing process surfaces as ESRCH or as EIO (sysctl returns a
		// zero-length result, which the wrapper rejects). Either way it's gone.
		if err == unix.ESRCH || err == unix.EIO || err == unix.ENOENT {
			return 0, "", "", errProcessGone
		}
		return 0, "", "", fmt.Errorf("proclive: sysctl kern.proc.pid %d: %w", pid, err)
	}
	tv := k.Proc.P_starttime
	return int(k.Eproc.Ppid),
		unix.ByteSliceToString(k.Proc.P_comm[:]),
		fmt.Sprintf("%d.%06d", tv.Sec, tv.Usec),
		nil
}

// bootID returns the kernel boot time (seconds), which changes on every reboot.
func bootID() (string, error) {
	tv, err := unix.SysctlTimeval("kern.boottime")
	if err != nil {
		return "", fmt.Errorf("proclive: sysctl kern.boottime: %w", err)
	}
	return strconv.FormatInt(tv.Sec, 10), nil
}
```

```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

//go:build linux

package proclive

import (
	"errors"
	"os"
	"strconv"
	"strings"
)

// procStat reads /proc/<pid>/stat and returns the parent PID, executable name
// (comm), and the process start time (field 22, in clock ticks since boot) used
// as the start fingerprint. The fingerprint only needs to be stable for the
// process lifetime and distinct across PID reuse within a boot; the boot guard
// in Check invalidates it across reboots, so raw ticks suffice and we avoid
// needing _SC_CLK_TCK.
func procStat(pid int) (ppid int, name, start string, err error) {
	data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return 0, "", "", errProcessGone
		}
		return 0, "", "", err
	}
	return parseProcStat(string(data))
}

// parseProcStat parses the contents of /proc/<pid>/stat. It is separated from
// the file read so it can be unit-tested with adversarial comm values.
//
// The comm (field 2) is wrapped in parentheses and may itself contain spaces
// and ')'. Everything before the first '(' is the PID; the comm runs to the
// LAST ')'; the remaining space-separated fields begin at 'state' (field 3).
func parseProcStat(content string) (ppid int, name, start string, err error) {
	openIdx := strings.IndexByte(content, '(')
	closeIdx := strings.LastIndexByte(content, ')')
	if openIdx < 0 || closeIdx < 0 || closeIdx < openIdx {
		return 0, "", "", errors.New("proclive: malformed /proc stat: no comm parens")
	}
	name = content[openIdx+1 : closeIdx]

// Fields after the comm, 0-indexed: 0=state (field 3), 1=ppid (field 4),
	// ... 19=starttime (field 22).
	const ppidIdx, starttimeIdx = 1, 19
	rest := strings.Fields(content[closeIdx+1:])
	if len(rest) <= starttimeIdx {
		return 0, "", "", errors.New("proclive: truncated /proc stat")
	}
	ppid, err = strconv.Atoi(rest[ppidIdx])
	if err != nil {
		return 0, "", "", err
	}
	return ppid, name, rest[starttimeIdx], nil
}

// bootID returns the kernel boot id, which changes on every reboot. It falls
// back to /proc/stat's btime line if boot_id is unavailable.
func bootID() (string, error) {
	if data, err := os.ReadFile("/proc/sys/kernel/random/boot_id"); err == nil {
		return strings.TrimSpace(string(data)), nil
	}
	data, err := os.ReadFile("/proc/stat")
	if err != nil {
		return "", err
	}
	for _, line := range strings.Split(string(data), "\n") {
		if rest, ok := strings.CutPrefix(line, "btime "); ok {
			return strings.TrimSpace(rest), nil
		}
	}
	return "", nil
}
```

//go:build-linux

package proclive

import (
	"strconv"
	"strings"
	"testing"
)

func TestParseProcStat_CommWithSpacesAndParens(t *testing.T) {
	t.Parallel()

// comm contains both spaces and ')', which must NOT confuse field parsing.
	const comm = "weird) proc name"
	const wantPPID = 1000
	const wantStart = "987654"

// Build the post-comm fields: index 0=state, 1=ppid, ..., 19=starttime.
	rest := make([]string, 20)
	for i := range rest {
		rest[i] = strconv.Itoa(i) // distinct filler so a wrong index is obvious
	}
	rest[0] = "R"
	rest[1] = strconv.Itoa(wantPPID)
	rest[19] = wantStart

content := "4242 (" + comm + ") " + strings.Join(rest, " ") + "\n"

ppid, name, start, err := parseProcStat(content)
	if err != nil {
		t.Fatalf("parseProcStat: %v", err)
	}
	if name != comm {
		t.Errorf("name = %q, want %q", name, comm)
	}
	if ppid != wantPPID {
		t.Errorf("ppid = %d, want %d", ppid, wantPPID)
	}
	if start != wantStart {
		t.Errorf("start = %q, want %q", start, wantStart)
	}
}

func TestParseProcStat_Malformed(t *testing.T) {
	t.Parallel()
	for _, content := range []string{
		"no parens here",
		"123 (proc) R 1", // truncated: too few post-comm fields
		"",
	} {
		if _, _, _, err := parseProcStat(content); err == nil {
			t.Errorf("parseProcStat(%q) = nil error, want error", content)
		}
	}
}
```

```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

//go:build !linux && !darwin

package proclive

// On platforms without process introspection (e.g. Windows), the seam reports
// "unsupported". Check then yields LivenessUnknown and ResolveOwner yields no
// owner, so session liveness degrades cleanly to the inactivity-timeout
// fallback instead of producing wrong answers.

func procStat(pid int) (ppid int, name, start string, err error) {
	return 0, "", "", errUnsupported
}

func bootID() (string, error) {
	return "", errUnsupported
}
```

```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

//go:build !linux && !darwin

package proclive

import (
	"os"
	"testing"
)

// On unsupported platforms liveness must degrade to Unknown (never a wrong
// Alive/Dead), so callers fall back to the inactivity timeout.
func TestCheck_UnsupportedIsUnknown(t *testing.T) {
	t.Parallel()
	id := Identity{PID: os.Getpid(), Start: "anything"}
	if got := Check(id); got != LivenessUnknown {
				t.Errorf("Check on unsupported platform = %v, want unknown", got)
	}
	if _, ok := ResolveOwner(); ok {
		t.Errorf("ResolveOwner on unsupported platform returned ok=true, want false")
	}
}
```

```go
// Package proclive captures a process's identity (PID plus a start-time
// fingerprint) and later reports whether that exact process is still alive.
//
// It exists to detect agent sessions left in an ACTIVE state when the owning
// process went away — a clean exit, a crash, a kill, a closed terminal, or a
// reboot — without firing a SessionStop hook. Recording the owner's identity at
// turn start lets `entire status` / `entire doctor` notice the process is gone
// immediately, instead of waiting out a coarse inactivity timeout.
//
// This package is a leaf: it imports only the standard library and
// golang.org/x/sys/unix. It must NOT import session, strategy, agent, or cli,
// so those packages can depend on it without an import cycle.
package proclive

import (
	"errors"
	"os"
	"strings"
)

// Liveness is the result of checking a recorded process Identity.
type Liveness int

const (
	// LivenessUnknown means liveness could not be determined: the identity is
	// empty, was recorded on another host, or the platform cannot introspect
	// processes. Callers should fall back to a time-based heuristic.
	LivenessUnknown Liveness = iota
	// LivenessAlive means the recorded process is still running.
	LivenessAlive
	// LivenessDead means the recorded process is gone (exited, killed, or the
	// machine rebooted) or its PID has been reused by a different process.
	LivenessDead
)

func (l Liveness) String() string {
	switch l {
	case LivenessAlive:
		return "alive"
	case LivenessDead:
		return "dead"
	case LivenessUnknown:
		return "unknown"
	default:
		return "unknown"
	}
}

// Identity fingerprints the process that owns a session turn. It is persisted
// in session state and later passed to Check. The zero value means "no owner
// recorded" and always yields LivenessUnknown.
type Identity struct {
	// PID is the operating-system process id of the owner.
	PID int `json:"pid"`
	// Start is an opaque, per-platform process start-time fingerprint. It need
	// only be stable for the process lifetime and distinct across PID reuse
	// within a single boot; the Boot guard invalidates it across reboots.
	Start string `json:"start"`
	// Boot identifies the current OS boot. A mismatch at check time means the
	// machine rebooted, so the recorded PID cannot still be the same process.
	Boot string `json:"boot,omitempty"`
	// Host is the hostname where the identity was recorded. PIDs are only
	// meaningful on their own machine, so a mismatch yields Unknown.
	Host string `json:"host,omitempty"`
	// Name is the owning process's executable name (comm). Diagnostic only.
	Name string `json:"name,omitempty"`
}

var (
	// errProcessGone is returned by procStat when no process with the given PID
	// exists. Check maps it to LivenessDead.
	errProcessGone = errors.New("proclive: process not found")
	// errUnsupported is returned by the per-platform seam when the OS cannot be
	// introspected (e.g. Windows). Check maps it to LivenessUnknown.
	errUnsupported = errors.New("proclive: unsupported platform")
)

// maxAncestorDepth bounds the ResolveOwner walk so a pathological or cyclic
// process tree can never loop or hang.
const maxAncestorDepth = 12

// transientNames are process names that are never the long-lived session owner:
// our own hook binary, the shells agents commonly use to exec hooks, and the Go
// toolchain (local-dev runs hooks via `go run`, whose short-lived `go` parent
// would otherwise be recorded as the owner and exit immediately). The walk skips
// past these to reach the real agent process. Note that interpreter runtimes
// (node, bun, python) are deliberately absent — for several agents the runtime
// IS the long-lived agent, so treating it as transient would skip the real owner.
var transientNames = map[string]bool{
	"entire": true,
	"sh":     true,
	"bash":   true,
	"zsh":    true,
	"dash":   true,
	"fish":   true,
	"ash":    true,
	"ksh":    true,
	"env":    true,
	"go":     true,
}

func isTransient(name string) bool {
	return transientNames[strings.ToLower(strings.TrimSpace(name))]
}

// ResolveOwner walks up the process tree from the current process and returns
// the Identity of the first ancestor that is not our own hook binary or a
// shell — i.e. the long-lived agent that owns this session.
//
// It returns (zero, false) when no such ancestor can be determined: an
// unsupported platform, a truncated/looping tree, or only transient ancestors.
// In that case the caller should record no owner and let liveness degrade to
// the time-based fallback. Resolving to nothing is always safer than recording
// a guessed PID, which could later be (mis)read as a live or dead owner.
func ResolveOwner() (Identity, bool) {
	// Host and boot are best-effort guards; an empty value simply disables that
	// guard in Check rather than failing resolution.
	host, err := os.Hostname()
	if err != nil {
		host = ""
	}
	boot, err := bootID()
	if err != nil {
		boot = ""
	}

// Walk up from our own process, reading each ancestor exactly once: procStat
	// returns its parent (to continue the walk), its name (to skip shells and our
	// own binary), and its start fingerprint (to record).
	candidate, _, _, err := procStat(os.Getpid())
	if err != nil {
		return Identity{}, false
	}
	for range maxAncestorDepth {
		if candidate <= 1 {
			return Identity{}, false
		}
		parent, name, start, err := procStat(candidate)
		if err != nil {
			return Identity{}, false
		}
		if !isTransient(name) {
			return Identity{PID: candidate, Start: start, Boot: boot, Host: host, Name: name}, true
		}
		candidate = parent
	}
	return Identity{}, false
}

// Check reports whether the process recorded in id is still alive.
//
// Precedence: an empty identity or a host mismatch is Unknown (cannot judge); a
// boot mismatch means a reboot, so the process is Dead; a missing PID or a
// start-fingerprint mismatch (PID reuse) is Dead; otherwise Alive. An
// unsupported platform is always Unknown so callers fall back to a timeout.
func Check(id Identity) Liveness {
	if id.PID <= 0 {
		return LivenessUnknown
	}
	if id.Host != "" {
		// Can't confirm we're on the recording host → can't trust its PIDs.
		host, err := os.Hostname()
		if err != nil || host != id.Host {
			return LivenessUnknown
		}
	}
	if id.Boot != "" {
		boot, err := bootID()
		switch {
		case err != nil || boot == "":
			return LivenessUnknown // can't confirm the boot → can't trust the PID
		case boot != id.Boot:
			return LivenessDead // rebooted: the process cannot have survived
		}
	}

_, _, start, err := procStat(id.PID)
	switch {
	case errors.Is(err, errUnsupported):
		return LivenessUnknown
	case errors.Is(err, errProcessGone):
		return LivenessDead
	case err != nil:
		// Transient/unexpected error: don't claim the process is dead.
		return LivenessUnknown
	}
	if id.Start != "" && start != "" && id.Start != start {
		// Same PID, different start time: the PID was reused by another process.
		return LivenessDead
	}
	return LivenessAlive
}
```

```go
// test file so on test platforms add known conditions and validate them in isolation.
package proclive

import (
	"os"
	"testing"
)

func TestCheck_EmptyIdentityIsUnknown(t *testing.T) {
	t.Parallel()
	if got := Check(Identity{}); got != LivenessUnknown {
		t.Errorf("Check(empty) = %v, want unknown", got)
	}
	if got := Check(Identity{PID: 0, Start: "x"}); got != LivenessUnknown {
		t.Errorf("Check(pid=0) = %v, want unknown", got)
	}
}
```

```go
// Add tests so that they report conditions for parameterized tests across both platforms.  For instance, if an identity is valid as in their initial GUID that some basic returned indication of being UNKNOWN.
package proclive

import (
	"os"
	"testing"
)

func TestCheck_HostMismatchIsUnknown(t *testing.T) {
	t.Parallel()
	// A recorded host that cannot match the current machine must yield Unknown
	// regardless of platform, before any process introspection happens.
	id := Identity{PID: os.Getpid(), Start: "anything", Host: "not-this-host-\x00-ever"}
	if got := Check(id); got != LivenessUnknown {
		t.Errorf("Check(host mismatch) = %v, want unknown", got)
	}
}
```

```go
// Validation for transient names of the agents.
func TestIsTransient(t *testing.T) {
	t.Parallel()
	for _, name := range []string{"entire", "sh", "bash", "ZSH", " dash ", "Fish", "go"} {
		if !isTransient(name) {
			t.Errorf("isTransient(%q) = false, want true", name)
		}
	}
	for _, name := range []string{"node", "bun", "claude", "cursor", "python3", ""} {
		if isTransient(name) {
			t.Errorf("isTransient(%q) = true, want false", name)
		}
	}
}
```
