template-jj/cmd/planctl/context.go
sid 935b86b791 feat(planctl): context-window token awareness — core (parent 1.0)
- TokenCtx + Classify + formatTokenCount (R2.2, R3.1-3.5, R4.4)
- ReadTokenCtx orchestrator: env > PID walk > transcript parse (R1.1-1.6, R2.1-2.6)
- parseLastUsage: 10MB scanner, JSONL last-assistant-wins, zero-usage qualifies (R2.1-2.3)
- parsePIDFile: strict two-line layout; rejects partial/corrupted writes (R6.2-6.3)
- resolveViaPIDWalk: 8-hop ancestor walk, liveness + start-stamp check (R1.2, R6.1, R6.3)
- proc_darwin.go (x/sys/unix kinfo_proc) + proc_linux.go (/proc/stat field 22)
- parseStatStartTime: platform-agnostic, handles comm with parens/spaces

Parent 1.0 from dev/plans/26174-planctl-context-tokens/tasks.md
2026-04-23 17:38:37 -06:00

240 lines
7.3 KiB
Go

package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// spec:26174-planctl-context-tokens/R2.3+D§3.5
var errNoUsageLine = errors.New("no assistant line with usage object")
// spec:26174-planctl-context-tokens/D§3.3
// pidSessionDir is the on-disk root for PID-keyed session files, written by the
// SessionStart hook. Injected as a variable so tests can stage fixtures in t.TempDir().
var pidSessionDir = "/tmp/planctl-sessions"
// pidWalkMaxHops is the ceiling on process-ancestor lookups during PID-file search (R1.2).
const pidWalkMaxHops = 8
// spec:26174-planctl-context-tokens/R1.1+R1.2+R1.3+R1.4+R1.5+R1.6+R2.1+R2.2+R2.3+R2.4+R2.5+R2.6+D§3.2+D§6.4
// ReadTokenCtx is the ambient constructor: it reads the calling process's
// environment and/or ancestor PID files to discover the session UUID, then
// opens the transcript and extracts the most recent API usage. Returns nil
// when any step fails — callers treat nil as "no context data". Never invokes
// a subprocess (design §6.4).
func ReadTokenCtx() *TokenCtx {
uuid := resolveSessionUUID()
if uuid == "" {
return nil
}
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
path := transcriptPath(uuid, home, os.Getenv("CLAUDE_PROJECT_DIR"))
used, err := parseLastUsage(path)
if err != nil {
return nil
}
return &TokenCtx{Used: used, Limit: readLimit()}
}
// spec:26174-planctl-context-tokens/R1.1+R1.2+R1.3+R1.4+R6.1+R6.3+D§3.3
// resolveSessionUUID returns the session UUID string, or "" if not found.
// Priority: CLAUDE_SESSION_ID env > PID-ancestor walk with liveness + start-stamp checks.
func resolveSessionUUID() string {
if v := os.Getenv("CLAUDE_SESSION_ID"); v != "" {
return v
}
return resolveViaPIDWalk(os.Getpid(), pidSessionDir, ppidOf, processAlive, processStartTimeSec)
}
// resolveViaPIDWalk does the ancestor-PID walk with pluggable platform helpers
// so tests can feed a synthetic chain without needing real processes.
func resolveViaPIDWalk(
startPID int,
dir string,
parentOf func(int) int,
alive func(int) bool,
startStampOf func(int) (int64, error),
) string {
pid := startPID
for hop := 0; hop < pidWalkMaxHops; hop++ {
pid = parentOf(pid)
if pid <= 1 {
return ""
}
path := filepath.Join(dir, strconv.Itoa(pid))
uuid, stamp, err := parsePIDFile(path)
if err != nil {
continue
}
if !alive(pid) {
continue
}
actual, err := startStampOf(pid)
if err != nil || actual != stamp {
continue
}
return uuid
}
return ""
}
// spec:26174-planctl-context-tokens/D§4.2
type transcriptUsage struct {
Type string `json:"type"`
Message struct {
Usage *struct {
InputTokens int64 `json:"input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
} `json:"usage"`
} `json:"message"`
}
// spec:26174-planctl-context-tokens/R2.1+R2.2+R2.3+R2.6+D§3.5
// parseLastUsage scans a JSONL transcript and returns the summed token count
// (input + cache_read + cache_creation) from the *last* line whose top-level
// "type" is "assistant" and whose "message.usage" object is present.
// Presence qualifies (per PRD R2.1) — the usage object may contain zeros.
// Malformed JSON lines are silently skipped. Returns errNoUsageLine when no
// qualifying line exists.
func parseLastUsage(path string) (int64, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 10<<20)
var last int64
var found bool
for sc.Scan() {
var rec transcriptUsage
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
}
if rec.Type != "assistant" || rec.Message.Usage == nil {
continue
}
u := rec.Message.Usage
last = u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens
found = true
}
if err := sc.Err(); err != nil {
return 0, err
}
if !found {
return 0, errNoUsageLine
}
return last, nil
}
// spec:26174-planctl-context-tokens/R6.2+R6.3+D§3.9+D§4.1
// parsePIDFile reads a PID file matching the exact layout in design §4.1:
//
// <session-uuid>\n
// <start-time-stamp>\n
//
// Start-time stamp is platform-specific (clock ticks since boot on Linux, epoch
// seconds on macOS — D§4.1 / §5.5). The parser is strict: both lines must be
// newline-terminated, uuid must be non-empty, stamp must parse as int64, and
// nothing may follow the second newline. Anything else (partial write, extra
// lines, missing final \n) is rejected — a corrupted file should not pass the
// liveness/start-stamp check in resolveViaPIDWalk.
func parsePIDFile(path string) (uuid string, startStamp int64, err error) {
data, err := os.ReadFile(path)
if err != nil {
return "", 0, err
}
rest, ok := strings.CutSuffix(string(data), "\n")
if !ok {
return "", 0, fmt.Errorf("pid file %s: missing terminating newline", path)
}
uuid, stampPart, ok := strings.Cut(rest, "\n")
if !ok {
return "", 0, fmt.Errorf("pid file %s: missing line 2 (start stamp)", path)
}
if uuid == "" {
return "", 0, fmt.Errorf("pid file %s: empty uuid", path)
}
if strings.Contains(stampPart, "\n") {
return "", 0, fmt.Errorf("pid file %s: unexpected trailing content", path)
}
stamp, perr := strconv.ParseInt(stampPart, 10, 64)
if perr != nil {
return "", 0, fmt.Errorf("pid file %s: malformed start stamp: %w", path, perr)
}
return uuid, stamp, nil
}
// spec:26174-planctl-context-tokens/R1.5+R1.6+D§3.4
// transcriptPath assembles the Claude Code transcript path.
// slug = projectDir with every '/' replaced by '-'. Falls back to os.Getwd() when projectDir is "".
func transcriptPath(uuid, home, projectDir string) string {
if projectDir == "" {
projectDir, _ = os.Getwd()
}
slug := strings.ReplaceAll(projectDir, "/", "-")
return filepath.Join(home, ".claude", "projects", slug, uuid+".jsonl")
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
// readLimit returns CLAUDE_CODE_MAX_CONTEXT_TOKENS as an int64, or 0 if unset/invalid.
func readLimit() int64 {
v := os.Getenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS")
if v == "" {
return 0
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return 0
}
return n
}
// spec:26174-planctl-context-tokens/R2.2+D§3.1
// TokenCtx holds the resolved token-usage data for the current session.
// Limit == 0 means the limit is unknown (CLAUDE_CODE_MAX_CONTEXT_TOKENS absent).
// A nil *TokenCtx means no session data was available; all emitters must
// handle nil gracefully (emit nothing extra).
type TokenCtx struct {
Used int64
Limit int64
}
// spec:26174-planctl-context-tokens/R4.4+D§3.8
func formatTokenCount(n int64) string {
if n < 1000 {
return "< 1 k"
}
k := (n + 500) / 1000
return strconv.FormatInt(k, 10) + " k"
}
// spec:26174-planctl-context-tokens/R3.1+R3.2+R3.3+R3.5+D§3.7
// Classify returns the band's severity label and recommendation suffix.
// Returns ("", "") for nil receiver, unknown limit (Limit == 0), or Normal band (pct < 70).
func (t *TokenCtx) Classify() (severity, msg string) {
if t == nil || t.Limit == 0 {
return "", ""
}
pct := t.Used * 100 / t.Limit
switch {
case pct < 70:
return "", ""
case pct < 85:
return "info", "plan to wrap up this session soon."
case pct < 95:
return "warn", "commit current work and start a new session after this task."
default:
return "error", "stop new work; commit and close out immediately."
}
}