diff --git a/internal/herdr/adapter.go b/internal/herdr/adapter.go index 3926d8d..94a1e67 100644 --- a/internal/herdr/adapter.go +++ b/internal/herdr/adapter.go @@ -3,6 +3,7 @@ package herdr import ( "context" "fmt" + "strings" "time" ) @@ -22,6 +23,9 @@ type TurnBoundary interface { type RotationSignal interface { RotationSignal(context.Context, Session) (string, error) } +type PaneExit interface { + PaneExited(context.Context, Session) (bool, error) +} type CLIAdapter struct { Client *Client Harness string @@ -61,6 +65,15 @@ func (a CLIAdapter) AtTurnBoundary(ctx context.Context, s Session) (bool, error) } return !IsBusy(r.Status), nil } +func (a CLIAdapter) PaneExited(ctx context.Context, s Session) (bool, error) { + var r struct { + Status string `json:"status"` + } + if err := a.Client.Call(ctx, "pane.status", s, &r); err != nil { + return false, err + } + return strings.EqualFold(r.Status, "exited") || strings.EqualFold(r.Status, "dead"), nil +} func (a CLIAdapter) RotationSignal(ctx context.Context, s Session) (string, error) { var r struct { Reason string `json:"reason"` diff --git a/internal/herdr/occupancy.go b/internal/herdr/occupancy.go index 908b323..6600e82 100644 --- a/internal/herdr/occupancy.go +++ b/internal/herdr/occupancy.go @@ -2,9 +2,16 @@ package herdr import ( "bufio" + "context" "encoding/json" + "fmt" + "io" + "net/http" "os" + "os/exec" + "path/filepath" "strings" + "time" ) type Usage struct{ Input, CacheRead, CacheWrite, Output int64 } @@ -48,6 +55,68 @@ func ClaudeUsage(p string) (Usage, error) { } return last, s.Err() } + +// ClaudeStopHook is the JSON contract supplied by Claude Code's stop hook. +// Keeping the hook parser here makes ingestion independent of the hook's shell. +type ClaudeStopHook struct { + TranscriptPath string `json:"transcript_path"` +} + +func ClaudeStopHookUsage(r io.Reader) (Usage, string, error) { + var h ClaudeStopHook + if err := json.NewDecoder(r).Decode(&h); err != nil { + return Usage{}, "", err + } + if h.TranscriptPath == "" { + return Usage{}, "", fmt.Errorf("claude stop hook: transcript_path required") + } + u, err := ClaudeUsage(h.TranscriptPath) + return u, h.TranscriptPath, err +} + +// CodexRolloutPaths discovers active rollouts from configured Codex state. +// sqlite3 is intentionally used as an optional bridge: Codex owns the schema +// and deployments may not ship a Go sqlite driver. +func CodexRolloutPaths(home string) ([]string, error) { + if home == "" { + home = os.Getenv("CODEX_HOME") + } + if home == "" { + home = filepath.Join(os.Getenv("HOME"), ".codex") + } + var paths []string + matches, _ := filepath.Glob(filepath.Join(home, "state_*.sqlite")) + for _, db := range matches { + out, err := exec.Command("sqlite3", db, "select rollout_path from threads where rollout_path is not null;").Output() + if err == nil { + for _, p := range strings.Fields(string(out)) { + if p != "" { + paths = append(paths, p) + } + } + } + } + if len(paths) == 0 { + paths, _ = filepath.Glob(filepath.Join(home, "sessions", "*", "*", "*", "rollout-*.jsonl")) + } + if len(paths) == 0 { + return nil, fmt.Errorf("codex: no active rollout found in %s", home) + } + return paths, nil +} +func CodexActiveUsage(home string) (Usage, string, error) { + paths, err := CodexRolloutPaths(home) + if err != nil { + return Usage{}, "", err + } + for i := len(paths) - 1; i >= 0; i-- { + if _, e := os.Stat(paths[i]); e == nil { + u, e := CodexUsage(paths[i]) + return u, paths[i], e + } + } + return Usage{}, "", os.ErrNotExist +} func CodexUsage(p string) (Usage, error) { f, e := os.Open(p) if e != nil { @@ -93,4 +162,33 @@ func OpenCodeUsage(p string) (Usage, error) { e = json.NewDecoder(f).Decode(&x) return Usage{x.Tokens.Input, x.Tokens.Cache.Read, x.Tokens.Cache.Write, x.Tokens.Output}, e } + +// OpenCodeStatus probes the server fast path. Callers can use the returned +// status and fall back to OpenCodeUsage when the SSE/server is unavailable. +func OpenCodeStatus(ctx context.Context, baseURL, sessionID string) (string, error) { + if baseURL == "" { + baseURL = "http://127.0.0.1:4096" + } + u := strings.TrimRight(baseURL, "/") + "/session/" + sessionID + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", err + } + c := &http.Client{Timeout: 5 * time.Second} + resp, err := c.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("opencode status: http %s", resp.Status) + } + var x struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&x); err != nil { + return "", err + } + return x.Status, nil +} func IsBusy(s string) bool { return strings.EqualFold(s, "busy") } diff --git a/internal/herdr/occupancy_test.go b/internal/herdr/occupancy_test.go new file mode 100644 index 0000000..3941df8 --- /dev/null +++ b/internal/herdr/occupancy_test.go @@ -0,0 +1,21 @@ +package herdr + +import ( + "strings" + "testing" +) + +func TestClaudeStopHookUsage(t *testing.T) { + // The hook parser's path validation is independent from transcript IO. + _, path, err := ClaudeStopHookUsage(strings.NewReader(`{"transcript_path":"/tmp/transcript.jsonl"}`)) + if path != "/tmp/transcript.jsonl" || err == nil { + t.Fatalf("path=%q err=%v", path, err) + } +} + +func TestCodexRolloutFallback(t *testing.T) { + paths, err := CodexRolloutPaths(t.TempDir()) + if err == nil || len(paths) != 0 { + t.Fatalf("paths=%v err=%v", paths, err) + } +}