- 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
240 lines
7.3 KiB
Go
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."
|
|
}
|
|
}
|