agentintel

package
v0.7.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 11, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Index

Constants

View Source
const (
	StgAgentDetect = "agent-intel/detect"
	StgAgentResult = "agent-intel/result"
	StgAgentStream = "agent-intel/stream"
)

STG constants for agent-intel operations.

View Source
const RecentFilesCap = 30

RecentFilesCap bounds how many recently-touched files we surface. The drawer's 文件 tab is a quick-jump surface, not an archive — 30 newest is plenty and keeps the JSONL scan cheap.

Variables

View Source
var (
	DetectTotal                 = obs.NewCounter("agent_intel_detect_total")
	DetectDuration              = obs.NewHistogram("agent_intel_detect_duration_seconds", obs.DefaultBuckets())
	AgentIntelWatchersActive    = obs.NewGauge("agent_intel_watchers_active")
	AgentIntelSubscribersActive = obs.NewGauge("agent_intel_subscribers_active")
	AgentIntelStatePushTotal    = obs.NewCounter("agent_intel_state_push_total")
	AgentIntelStateDropTotal    = obs.NewCounter("agent_intel_state_drop_total")
	AgentIntelWatcherErrors     = obs.NewCounter("agent_intel_watcher_errors_total")
)

Metrics — incremented by agent_intel_routes.go.

View Source
var Logger = obs.Module("agent-intel")

Logger is the obs-native logger for agent-intel, exported for routes to use.

View Source
var SharedProcessInspector = NewProcessInspector()

SharedProcessInspector is a global singleton so all sidecars share one ps snapshot instead of each forking their own.

Functions

func CodexNewestRolloutForCWD

func CodexNewestRolloutForCWD(pl *ProjectLocator, cwd string) string

CodexNewestRolloutForCWD returns the path of the newest Codex rollout for cwd (preferring a recorded-cwd match), or "". Exported wrapper so callers outside this package (e.g. the notifier) can resolve a Codex transcript name.

func CodexRolloutExistsForCWD

func CodexRolloutExistsForCWD(pl *ProjectLocator, cwd string) bool

CodexRolloutExistsForCWD reports whether a Codex rollout can be located for cwd. The overview handler uses it as the fallback signal: when the Claude extractor yields zero turns AND a codex rollout exists, it routes to codex even without the explicit tool param.

func LogStateResolved

func LogStateResolved(ctx context.Context, sessionID, mode string, state AgentState, elapsed time.Duration)

LogStateResolved records high-frequency state probes without flooding TS-OBS. It emits immediately when the semantic state changes, otherwise coalesces repeated polling observations into one interval summary.

func LogTmuxScanComplete

func LogTmuxScanComplete(ctx context.Context, sessionID, sessionName string, panes, agents int)

LogTmuxScanComplete is the tmux equivalent of LogStateResolved: topology changes are emitted immediately; unchanged scans are summarized per window.

Types

type AgentIntelMonitorManager

type AgentIntelMonitorManager struct {
	// contains filtered or unexported fields
}

AgentIntelMonitorManager owns the per-session agent-state watchers behind a streaming transport. It ref-counts watchers so concurrent subscribers to one session share a single watcher, torn down when the last subscriber leaves.

It runs in one of two modes, chosen at construction:

  • resolver mode: a SnapshotWatcher polls an AgentStateResolver (tmux/process aware) — the host owns state resolution end to end.
  • session mode: a JSONL AgentStateWatcher tails the agent transcript located from a SessionActivity, with the terminal output as an idle source.

func NewAgentIntelMonitorManager

func NewAgentIntelMonitorManager(getter SessionActivityGetter, locator *ProjectLocator) *AgentIntelMonitorManager

NewAgentIntelMonitorManager builds a manager in session mode over getter. Pass a nil getter only when you also intend the resolver path (prefer NewAgentIntelMonitorManagerWithResolver for that).

func NewAgentIntelMonitorManagerWithResolver

func NewAgentIntelMonitorManagerWithResolver(
	getter SessionActivityGetter,
	locator *ProjectLocator,
	resolver AgentStateResolver,
) *AgentIntelMonitorManager

NewAgentIntelMonitorManagerWithResolver builds a manager. When resolver is non-nil it takes precedence (resolver mode) and getter may be nil; otherwise getter drives session mode. locator defaults when nil.

func (*AgentIntelMonitorManager) Subscribe

func (m *AgentIntelMonitorManager) Subscribe(ctx context.Context, sessionID string) (<-chan AgentIntelResponse, func(), error)

Subscribe returns a channel of state changes for sessionID. The release function is idempotent and stops the watcher when the last subscriber leaves.

type AgentIntelResponse

type AgentIntelResponse struct {
	// Current is the agent state for the active tmux window (or direct session).
	// Includes token data from JSONL. Null if no agent in the active pane.
	Current *AgentState `json:"current"`

	// Notifications lists all panes across the tmux session that need user input.
	// Includes panes from non-active windows. Empty if no panes need input.
	Notifications []AgentState `json:"notifications"`
}

AgentIntelResponse is the full API response for a CLI session's agent intelligence.

type AgentSnapshotWatcher

type AgentSnapshotWatcher struct {
	// contains filtered or unexported fields
}

AgentSnapshotWatcher publishes the same tmux-aware state used by the HTTP snapshot endpoint. It is intentionally polling-based because tmux topology changes do not emit JSONL/fsnotify events.

func NewAgentSnapshotWatcher

func NewAgentSnapshotWatcher(sessionID string, resolver AgentStateResolver) *AgentSnapshotWatcher

func (*AgentSnapshotWatcher) Start

func (w *AgentSnapshotWatcher) Start(parent context.Context)

func (*AgentSnapshotWatcher) Stop

func (w *AgentSnapshotWatcher) Stop()

type AgentState

type AgentState struct {
	Tool       AgentTool   `json:"tool"`
	Status     AgentStatus `json:"status"`
	WaitReason WaitReason  `json:"waitReason,omitempty"`
	Model      string      `json:"model,omitempty"`

	// AwaitingUser: the agent completed a turn and has NOT been responded to yet —
	// idle-after-a-turn or blocked (waiting), but not fresh-idle (never ran) and not
	// running. Drives the "needs-you" pane-bar dot + notification; clears when the
	// user's next input lands (the agent goes running again). Derived from transcript
	// timestamps so it survives a page reload — no need to witness the live transition.
	AwaitingUser bool `json:"awaitingUser,omitempty"`

	// AwaitingSince is the transcript timestamp of the turn-completion that put the agent
	// into the current AwaitingUser state (last assistant turn for Claude, last task_complete
	// for Codex). It is DERIVED FROM THE TRANSCRIPT, so — exactly like AwaitingUser — it
	// survives a page reload: the same completion always yields the same value, and a NEW turn
	// yields a new one. The frontend keys its per-window "seen" layer on this so a dismissed
	// dot stays dismissed across F5 yet re-appears when the pane completes another turn.
	// Zero when not awaiting. [needs-you dot persistence]
	AwaitingSince time.Time `json:"awaitingSince,omitempty"`

	// Token usage (from JSONL parsing)
	InputTokens       int `json:"inputTokens"`
	OutputTokens      int `json:"outputTokens"`
	CacheReadTokens   int `json:"cacheReadTokens"`
	CacheCreateTokens int `json:"cacheCreateTokens"`
	TotalTokens       int `json:"totalTokens"`

	// tmux pane info (nil if not in tmux)
	TmuxWindow *int `json:"tmuxWindow,omitempty"` // ctrl+b+N
	TmuxPane   *int `json:"tmuxPane,omitempty"`

	// Timing
	StartedAt time.Time `json:"startedAt,omitempty"`
	UpdatedAt time.Time `json:"updatedAt"`

	// Internal: which signals contributed to this state
	SignalSource string `json:"-"` // "jsonl", "process", "pty_idle", "output"
}

AgentState is the full state of an AI agent in a CLI session.

type AgentStateResolver

type AgentStateResolver func(ctx context.Context, sessionID string) (AgentIntelResponse, error)

AgentStateResolver produces the full agent-intel response for one session. It is the generic seam between a host's session model and the snapshot watcher: the host supplies a resolver that knows how to translate a session ID into tmux/process state, and AgentSnapshotWatcher polls it.

The orchestration that drives watchers (AgentIntelMonitorManager) lives in this package too — decoupled from any host via the SessionActivity seam — so the engine is self-contained with no import cycle when a host depends on it.

type AgentStateWatcher

type AgentStateWatcher struct {
	// contains filtered or unexported fields
}

AgentStateWatcher tails one CLI session's JSONL transcript and fans out derived state changes to subscribers.

func NewAgentStateWatcher

func NewAgentStateWatcher(sessionID, cwd string, tool AgentTool, locator *ProjectLocator) *AgentStateWatcher

NewAgentStateWatcher creates a watcher for one CLI session. Use Start before expecting fsnotify delivery; Subscribe can be called before or after Start.

func (*AgentStateWatcher) SetPTYIdleSource

func (w *AgentStateWatcher) SetPTYIdleSource(src PTYIdleSource)

SetPTYIdleSource injects a PTY activity source for idle detection. Must be called before Start. When set, the watcher probes terminal activity to override "running" state when the PTY has been silent.

func (*AgentStateWatcher) Start

func (w *AgentStateWatcher) Start(parent context.Context)

Start begins the fsnotify loop.

func (*AgentStateWatcher) Stop

func (w *AgentStateWatcher) Stop()

Stop terminates the watcher and closes all subscriber channels.

type AgentStatus

type AgentStatus string

AgentStatus represents the 5-state lifecycle (aligned with Daintree/Canopy).

const (
	StatusNone    AgentStatus = "none"    // No agent detected
	StatusRunning AgentStatus = "running" // Agent actively working
	StatusIdle    AgentStatus = "idle"    // Turn completed, waiting for next prompt
	StatusWaiting AgentStatus = "waiting" // Needs human input (permission/question)
	StatusDone    AgentStatus = "done"    // Agent process exited
)

type AgentTool

type AgentTool string

AgentTool identifies which AI CLI tool is running.

const (
	ToolNone     AgentTool = ""
	ToolClaude   AgentTool = "claude"
	ToolCodex    AgentTool = "codex"
	ToolGemini   AgentTool = "gemini"
	ToolOpenCode AgentTool = "opencode"
)

type ClaudeDriver

type ClaudeDriver struct {
	// contains filtered or unexported fields
}

ClaudeDriver parses a Claude Code JSONL transcript and derives session state.

func NewClaudeDriver

func NewClaudeDriver(jsonlPath, sessionID string) *ClaudeDriver

NewClaudeDriver creates a driver for the given JSONL path. sessionID is used as part of the dedup key for CountedUsageKey.

func (*ClaudeDriver) AgentState

func (cd *ClaudeDriver) AgentState() AgentState

AgentState converts to the unified AgentState model.

func (*ClaudeDriver) State

func (cd *ClaudeDriver) State() ClaudeSessionState

State returns the current derived state.

func (*ClaudeDriver) Update

func (cd *ClaudeDriver) Update() error

Update reads new JSONL lines and updates state.

type ClaudeSessionState

type ClaudeSessionState struct {
	Model           string
	Status          AgentStatus
	WaitReason      WaitReason
	Usage           UsageTotals
	LastUserAt      time.Time
	LastAssistAt    time.Time
	StopReason      string
	PendingTool     string // name of the unresolved tool_use (for elicitation detection); "" when none
	LastMsgQuestion bool   // the last assistant turn ended on a free-text question (heuristic)
	UpdatedAt       time.Time
}

ClaudeSessionState tracks the state derived from Claude JSONL parsing.

type CodexDriver

type CodexDriver struct {
	// contains filtered or unexported fields
}

CodexDriver parses a Codex CLI JSONL rollout file and derives session state.

func NewCodexDriver

func NewCodexDriver(jsonlPath string) *CodexDriver

func (*CodexDriver) AgentState

func (cd *CodexDriver) AgentState() AgentState

AgentState converts to the unified AgentState model.

func (*CodexDriver) State

func (cd *CodexDriver) State() CodexSessionState

State returns the current derived state.

func (*CodexDriver) Update

func (cd *CodexDriver) Update() error

Update reads new JSONL lines and updates state.

type CodexSessionState

type CodexSessionState struct {
	SessionID    string
	Model        string
	CWD          string
	Status       AgentStatus
	Awaiting     bool      // a turn completed (task_complete) and no new turn started since = needs-you
	LastTurnAt   time.Time // transcript time of the last task_complete — the reload-proof "completed at" behind Awaiting
	InputTokens  int
	OutputTokens int
	CachedTokens int
	TotalTokens  int
	UpdatedAt    time.Time
}

CodexSessionState tracks the state derived from Codex JSONL parsing.

type CountedUsageKey

type CountedUsageKey struct {
	SessionID string
	MessageID string
}

CountedUsageKey deduplicates streaming token usage by (sessionID, messageID). Claude JSONL has 2-10 duplicate rows per message with cumulative usage values. We track max per key and only accumulate the delta.

type JSONLReader

type JSONLReader struct {
	// contains filtered or unexported fields
}

JSONLReader reads JSONL files incrementally using offset tracking. Half-written lines (no trailing \n) are NOT consumed — they will be re-read on the next call once the line is complete. This prevents data loss when fsnotify fires mid-write. [Ref: TH-0501-m9j P1 fix, Codex review]

func NewJSONLReader

func NewJSONLReader(path string) *JSONLReader

func (*JSONLReader) Offset

func (r *JSONLReader) Offset() int64

Offset returns current read position.

func (*JSONLReader) ReadNew

func (r *JSONLReader) ReadNew() ([]map[string]any, error)

ReadNew reads only new lines since last read. Returns parsed JSON objects. If file was truncated (size < offset), resets offset to 0.

func (*JSONLReader) ReadNewFunc

func (r *JSONLReader) ReadNewFunc(fn func(row map[string]any) bool) error

ReadNewFunc calls fn for each new complete line. Stops if fn returns false. Incomplete lines (no trailing \n at EOF) are left unconsumed so they can be re-read when the writer finishes flushing.

func (*JSONLReader) ReadTailFunc added in v0.3.1

func (r *JSONLReader) ReadTailFunc(maxBytes int64, fn func(row map[string]any) bool) error

ReadTailFunc parses only the LAST maxBytes of the file (line-aligned) and calls fn for each complete row in file order within that window. If the file is <= maxBytes it reads the whole file. Use this when you only need RECENT rows (e.g. recent edited files) and must not parse a multi-MB transcript from the start. When the window starts mid-file the first (partial) line is dropped, and a trailing half-written line fails to parse and is skipped. It does NOT track offset — each call reads the tail window fresh.

type PTYIdleSource

type PTYIdleSource interface {
	// LastActivity returns the most recent timestamp when the terminal produced output.
	LastActivity() time.Time

	// TailLines returns the last n lines of visible terminal output for output analysis.
	TailLines(n int) []string
}

PTYIdleSource provides terminal activity signals for idle detection. The watcher uses this to supplement JSONL-based state with PTY reality checks.

type PaneAgentMonitor

type PaneAgentMonitor struct {
	// contains filtered or unexported fields
}

PaneAgentMonitor answers one cheap question per agent pane: is its JSONL TRANSCRIPT being written right now? That alone separates "actively working" (skip the terminal) from "stopped" (read the terminal to see if it's blocked on a prompt). The transcript path is located once per pane and cached — locating it scans a project dir (some hold hundreds of files), so we don't repeat it every poll; only the cheap os.Stat for the mtime runs each time.

Status itself (running / waiting / idle) is NOT decided here: the permission/selection/input PROMPT is terminal UI, absent from the transcript, so it can only be read from the pane. This type is purely the gate that keeps that read off the hot path for busy panes.

func NewPaneAgentMonitor

func NewPaneAgentMonitor(locator *ProjectLocator) *PaneAgentMonitor

func (*PaneAgentMonitor) Active

func (m *PaneAgentMonitor) Active(key, cwd string, tool AgentTool) bool

Active reports whether the pane's transcript was written within transcriptActiveWindow — i.e. the agent is currently producing output. Unknown transcript (not locatable yet) counts as active so a freshly-seen agent pane is never wrongly read as a prompt before we know better.

func (*PaneAgentMonitor) Awaiting added in v0.7.6

func (m *PaneAgentMonitor) Awaiting(key, cwd string, tool AgentTool) bool

Awaiting reports whether the pane's agent finished a turn and is waiting on the user (needs-you). Cheap: reuses the driver already updated by Status() this poll, so call it right after Status() with no extra transcript read.

func (*PaneAgentMonitor) AwaitingSince added in v0.7.6

func (m *PaneAgentMonitor) AwaitingSince(key string) time.Time

AwaitingSince returns the transcript timestamp of the turn-completion behind the pane's current needs-you state (zero when not awaiting or the driver isn't cached). Reuses the driver already updated by Status() this poll — no extra transcript read. The frontend keys its reload-proof "seen" layer on this. Call right after Status()/Awaiting().

func (*PaneAgentMonitor) Prune

func (m *PaneAgentMonitor) Prune(keep map[string]bool)

Prune drops cache entries for panes no longer present. Call once per topology recompute.

func (*PaneAgentMonitor) Status added in v0.3.0

func (m *PaneAgentMonitor) Status(key, cwd string, tool AgentTool) (AgentStatus, bool)

Status returns the JSONL-derived agent status for a pane via a cached INCREMENTAL driver (each poll parses only new transcript lines). This is the accurate signal — it knows the pending tool's NAME, so an AskUserQuestion reads as waiting-for-the- user and a Bash/Read reads as executing=running, where a mtime/terminal heuristic cannot tell them apart. ok is false when the transcript isn't locatable yet (the caller then falls back to the terminal read).

type PriceJSON

type PriceJSON struct {
	Input            float64 `json:"input"`
	Output           float64 `json:"output"`
	CacheRead        float64 `json:"cache_read"`
	CacheWrite5m     float64 `json:"cache_write_5m"`
	CacheWrite1h     float64 `json:"cache_write_1h"`
	Currency         string  `json:"currency"`
	ContextThreshold int     `json:"context_threshold,omitempty"`
}

PriceJSON is the current model's reference unit price, in currency per MILLION tokens, mirrored from pricing.ModelPrice's BASE Tier. cache_write_5m / cache_write_1h are 0 for providers without a cache-write tier (OpenAI, Gemini). context_threshold is the long-context premium boundary in tokens (0 = no tier). It is only emitted when the model resolves in the kit/pricing table.

type ProcessInfo

type ProcessInfo struct {
	PID     int
	PPID    int
	PGID    int
	Command string
}

ProcessInfo holds parsed fields from ps output.

type ProcessInspector

type ProcessInspector struct {
	// contains filtered or unexported fields
}

ProcessInspector detects AI CLI tools in the process tree. Uses a shared snapshot cache (3s TTL) so multiple sidecars don't each fork their own ps process.

func NewProcessInspector

func NewProcessInspector() *ProcessInspector

func (*ProcessInspector) DetectTool

func (pi *ProcessInspector) DetectTool(shellPID int) AgentTool

DetectTool finds the AI CLI tool running under shellPID. Returns ToolNone if no known tool is found. The context controls the subprocess timeout (ps command).

func (*ProcessInspector) DetectToolCtx

func (pi *ProcessInspector) DetectToolCtx(ctx context.Context, shellPID int) AgentTool

DetectToolCtx is the context-aware variant of DetectTool.

func (*ProcessInspector) HasActiveCommand

func (pi *ProcessInspector) HasActiveCommand(shellPID int) bool

HasActiveCommand checks if the shell has any non-shell child process running.

type ProjectLocator

type ProjectLocator struct{}

ProjectLocator maps CWD to Claude/Codex project directories.

func NewProjectLocator

func NewProjectLocator() *ProjectLocator

func (*ProjectLocator) ClaudeAllSessionFiles

func (pl *ProjectLocator) ClaudeAllSessionFiles() []string

ClaudeAllSessionFiles returns every .jsonl across ALL Claude projects, sorted newest-first by modification time. Used by the cross-project input drawer. Missing root → empty slice.

func (*ProjectLocator) ClaudeProjectDir

func (pl *ProjectLocator) ClaudeProjectDir(projectPath string) string

ClaudeProjectDir returns the Claude JSONL directory for a project path. Claude Code encodes the project path by replacing '/' and '.' with '-'. Example: /Users/foo/proj → ~/.claude/projects/-Users-foo-proj/ Important: resolves symlinks first (macOS /tmp → /private/tmp).

func (*ProjectLocator) ClaudeProjectsRoot

func (pl *ProjectLocator) ClaudeProjectsRoot() string

ClaudeProjectsRoot returns ~/.claude/projects.

func (*ProjectLocator) ClaudeSessionFiles

func (pl *ProjectLocator) ClaudeSessionFiles(projectPath string) ([]string, error)

ClaudeSessionFiles returns all .jsonl files in the project directory, sorted by modification time (newest first).

func (*ProjectLocator) CodexLatestSession

func (pl *ProjectLocator) CodexLatestSession(projectPath string) (string, error)

CodexLatestSession finds the most recent Codex rollout JSONL.

It MUST use the recursive walk (CodexSessionFiles): Codex nests rollouts by date under sessions/YYYY/MM/DD/rollout-*.jsonl, so a flat os.ReadDir of the base dir finds only the year DIRECTORIES and zero .jsonl files — which made this always return ErrNotExist. That in turn left every Codex pane's transcript unlocatable, so PaneAgentMonitor.Active fell back to "assume busy" and the pane read as perpetually Running → the running→waiting transition that fires the turn-end push never happened → Codex sessions never notified. (projectPath is not used yet — Codex rollouts aren't keyed by cwd; newest wins, matching CodexSessionFiles. Per-cwd matching would parse each rollout's session_meta.cwd, a future refinement.)

func (*ProjectLocator) CodexSessionDir

func (pl *ProjectLocator) CodexSessionDir() string

CodexSessionDir returns the Codex sessions base directory.

func (*ProjectLocator) CodexSessionFiles

func (pl *ProjectLocator) CodexSessionFiles() []string

CodexSessionFiles returns every Codex rollout JSONL under ~/.codex/sessions, sorted newest-first by modification time. Codex nests rollouts by date (sessions/YYYY/MM/DD/rollout-*.jsonl), so a recursive walk is required — a flat ReadDir of the base dir finds nothing. Missing base dir → empty slice.

type PromptState

type PromptState int

PromptState indicates what the terminal output suggests about agent state.

const (
	PromptUnknown         PromptState = iota
	PromptRunning                     // Active output / spinner detected
	PromptLikelyIdle                  // Prompt char detected but no confirming status line
	PromptIdle                        // Prompt char + confirming context (e.g. model name visible)
	PromptNeedsPermission             // [Y/n] or similar approval prompt
	PromptDone                        // Process exited
)

func AnalyzeOutput

func AnalyzeOutput(lines []string) PromptState

AnalyzeOutput examines the last few lines of terminal output to detect prompt state. This is the LOWEST priority signal — only used when JSONL and process signals are ambiguous. Uses structural patterns only; no hardcoded tool-specific strings.

type RecentFile

type RecentFile struct {
	Path   string `json:"path"`
	Name   string `json:"name"`
	Dir    string `json:"dir"`
	Tool   string `json:"tool"`
	TsMs   int64  `json:"tsMs"`
	Size   int64  `json:"size"`
	Exists bool   `json:"exists"`
}

RecentFile is one file an agent wrote/edited, attributed from a transcript tool_use row. tsMs is the row timestamp (newest occurrence wins on dedup). Size and Exists are stat'd by the caller — a vanished file is still listed with Exists=false (它可能已被删除,仍是有用的历史信号).

func RecentEditedFiles

func RecentEditedFiles(pl *ProjectLocator, projectCWD string) []RecentFile

RecentEditedFiles scans every Claude transcript whose cwd matches projectCWD and extracts the file paths agents wrote/edited (Write/Edit/MultiEdit/NotebookEdit tool_use blocks → input.file_path). It dedupes by path keeping the NEWEST tsMs, sorts newest-first, and caps at RecentFilesCap. Size/Exists are NOT filled here (no os.Stat) — that is the handler's job, keeping this pure and unit-testable.

Codex apply_patch / function_call paths are defensively attempted but, since the rollout format is not yet stable, missing data is simply skipped (never an error).

A missing transcript store, a malformed line, or an unreadable file never fails — it just yields fewer (or zero) files. Empty is a valid answer.

type SessionActivity

type SessionActivity interface {
	// WorkingDir is the session's working directory, used to locate the
	// agent's JSONL transcript.
	WorkingDir() string

	// Engine names the agent CLI driving the session ("claude", "codex", …);
	// it selects the JSONL driver. Empty means no known agent.
	Engine() string

	// LastActivity returns the most recent time the terminal produced output.
	LastActivity() time.Time

	// TailLines returns the last n lines of visible terminal output.
	TailLines(n int) []string
}

SessionActivity is the host-agnostic view the monitor needs of one live session. It is the only seam between this transport- and session-model- agnostic engine and whatever session model a host runs (a terminal PTY session, a remote mux session, a test fake). Implementors expose just enough for JSONL-based agent-state detection: where the agent runs (CWD/Engine) and how the terminal is behaving (last activity + recent output tail).

type SessionActivityGetter

type SessionActivityGetter func(ctx context.Context, sessionID string) (SessionActivity, bool)

SessionActivityGetter resolves a session ID to its live activity view. The second return is false when the session is unknown. A host supplies this to drive the JSONL-watcher path; pass nil to use the resolver path instead.

type SessionDetail

type SessionDetail struct {
	ID        string  `json:"id"`
	Title     string  `json:"title"`
	Active    bool    `json:"active"`
	TurnCount int     `json:"turn_count"`
	ModelID   string  `json:"model_id"`
	CreatedAt string  `json:"created_at"`
	UpdatedAt string  `json:"updated_at"`
	EndedAt   *string `json:"ended_at"`
}

SessionDetail is the session-level header. ended_at is always null (a live session has no end); active is passed in by the caller (is the agent currently running?).

type SessionMetrics

type SessionMetrics struct {
	Detail  SessionDetail  `json:"detail"`
	Summary SessionSummary `json:"summary"`
	Turns   []TurnMetrics  `json:"turns"`
	Price   *PriceJSON     `json:"price,omitempty"`
}

SessionMetrics is the full overview payload for ONE agent session: a per-turn breakdown, an aggregate summary, and session-level detail. It feeds the shared @ce OverviewPanel. Group-B metrics (agent/model calls, permission requests, tool-call categories) are NOT derivable from a Claude transcript and are left nil → serialized as JSON null → the UI renders "—". Cost is the exception: it IS derivable (summed tokens × kit/pricing) so summary.total_cost is computed here.

Price is the current model's reference UNIT price (per-MTok), looked up once from the kit/pricing SSOT. It is nil (omitted) when the model is unknown to the table — never a fabricated 0.

func CodexSessionMetricsForCWD

func CodexSessionMetricsForCWD(pl *ProjectLocator, cwd, sessionID, title string, active bool) SessionMetrics

CodexSessionMetricsForCWD locates the CURRENT Codex rollout for cwd and parses it into the SAME SessionMetrics shape as the Claude extractor — so the shared @ce OverviewPanel renders a Codex pane identically to a Claude one.

Rollout selection: Codex nests rollouts by date and does NOT encode the project path into the directory name (unlike Claude). So we walk every rollout newest-first and pick the newest whose session_meta.cwd (or any turn_context.cwd) matches cwd. If none match (cwd unknown / older format), we fall back to the newest rollout overall — the live pane is almost always the most recently written file.

Robustness contract (mirrors SessionMetricsForCWD): a missing ~/.codex/sessions, no rollout, malformed rows — none are errors. They yield a valid-but-empty SessionMetrics (turn_count 0, Group-B nil). The caller never branches on tool type.

func SessionMetricsForCWD

func SessionMetricsForCWD(pl *ProjectLocator, cwd, sessionID, title string, active bool) SessionMetrics

SessionMetricsForCWD locates the CURRENT Claude transcript for cwd (the most recently MODIFIED .jsonl in the project's transcript dir) and parses it into turns + summary + detail. sessionID and title are echoed into detail; active flags whether the agent is presently running.

Robustness contract (mirrors recent_files.go): a missing transcript dir, a codex session with no Claude transcript, malformed rows — none are errors. They yield a valid-but-empty SessionMetrics (turn_count 0, Group-B nil). The caller never has to branch on tool type.

type SessionSummary

type SessionSummary struct {
	UserPrompts       int    `json:"user_prompts"`
	TurnCount         int    `json:"turn_count"`
	StartedAt         string `json:"started_at"`
	TotalDurationMs   int64  `json:"total_duration_ms"`
	InputTokens       int    `json:"input_tokens"`
	OutputTokens      int    `json:"output_tokens"`
	CacheReadTokens   int    `json:"cache_read_tokens"`
	CacheCreateTokens int    `json:"cache_create_tokens"`
	ToolCalls         int    `json:"tool_calls"`
	ToolErrors        *int   `json:"tool_errors"`
	OutputWindowMs    *int64 `json:"output_window_ms"`

	// Group B — not derivable from the transcript. Always null.
	ModelCalls          *int    `json:"model_calls"`
	AgentCalls          *int    `json:"agent_calls"`
	PermissionRequests  *int    `json:"permission_requests"`
	ToolCallsByCategory *string `json:"tool_calls_by_category"`

	// Cost IS derivable: summed tokens × kit/pricing for the session model. nil when
	// the model is unknown to the price table (never a fabricated 0). currency is
	// omitted when there is no cost.
	TotalCost *float64 `json:"total_cost"`
	Currency  string   `json:"currency,omitempty"`
}

SessionSummary aggregates every turn. Group-A fields (counts/tokens/durations) are real sums. tool_errors is nil when no tool_result carried is_error info. Group-B fields are always nil (transcript can't reveal them honestly).

func SessionSummaryForPath added in v0.3.0

func SessionSummaryForPath(path, tool string) SessionSummary

SessionSummaryForPath parses a KNOWN transcript path into its summary, dispatching by tool. The single path-based resolver: a caller that already resolved a pane's transcript path must use this rather than re-resolving "newest by cwd" (which drifts when several sessions share a directory). Zero summary on empty input.

type TmuxPane

type TmuxPane struct {
	SessionName    string
	SessionWindow  string // "session:window" for capture-pane target
	WindowIndex    int
	WindowName     string
	PaneIndex      int
	PanePID        int
	PaneCWD        string // pane's current working directory
	PaneID         string // stable tmux pane id ("%N") — survives index reuse / window reorder
	WindowID       string // stable tmux window id ("@N") — survives window index reuse / reorder
	Active         bool   // window is the active window in the session
	PaneActive     bool   // this pane is the active pane WITHIN its window (target of a bare session:window capture)
	LastActivityAt int64  // unix timestamp of last pane activity (from tmux)
}

TmuxPane represents a single tmux pane with its process info.

type TmuxPaneState

type TmuxPaneState struct {
	Index       int         `json:"index"`
	Active      bool        `json:"active"`
	Title       string      `json:"title"`
	PID         int         `json:"pid"`
	CWD         string      `json:"cwd"`
	PaneID      string      `json:"paneId,omitempty"` // stable tmux pane id ("%N")
	AgentTool   AgentTool   `json:"agentTool,omitempty"`
	AgentStatus AgentStatus `json:"agentStatus,omitempty"`
	// AwaitingUser: the agent completed a turn / is blocked and hasn't been responded
	// to — drives the "needs-you" dot. Distinct from AgentStatus==idle, which also
	// covers a fresh pane that never ran a turn (not awaiting).
	AwaitingUser bool `json:"awaitingUser,omitempty"`
	// AwaitingSince: transcript time of the completion behind AwaitingUser (zero when not
	// awaiting). Reload-proof (transcript-derived) → the frontend keys its per-window "seen"
	// dismissal on it so a cleared dot stays cleared across F5 yet re-appears on a new turn.
	AwaitingSince time.Time `json:"awaitingSince,omitempty"`
}

TmuxPaneState is one pane within the topology, enriched with agent detection.

type TmuxPrefix

type TmuxPrefix struct {
	Display string `json:"display"`
	Bytes   []byte `json:"bytes"`
}

TmuxPrefix is the resolved tmux prefix key. Display is the human label (e.g. "C-b"); Bytes is the control byte(s) the client must send to emulate the prefix (e.g. C-b → 0x02, C-a → 0x01).

type TmuxProber

type TmuxProber struct {
	// contains filtered or unexported fields
}

TmuxProber performs zero-invasion tmux introspection via read-only commands.

func NewTmuxProber

func NewTmuxProber(inspector *ProcessInspector) *TmuxProber

func (*TmuxProber) CapturePane

func (tp *TmuxProber) CapturePane(ctx context.Context, sessionWindow string, paneIdx, lines int) ([]string, error)

CapturePane reads the last n visible lines of a tmux pane (zero-invasion, read-only). The agent's permission / selection / input PROMPT lives in the terminal, not its JSONL transcript, so this is the ground-truth source for "blocked waiting for the user" — gated on transcript inactivity so it is read only for panes that have stopped producing output (see PaneAgentMonitor).

func (*TmuxProber) CaptureWindowTail added in v0.7.0

func (tp *TmuxProber) CaptureWindowTail(ctx context.Context, sessionWindow string, tool AgentTool, lines int) ([]string, error)

CaptureWindowTail captures a WINDOW's active-pane live tail for the Agent Overview: the last `lines` lines of REAL output after `tool`'s persistent bottom chrome (input box / status / token counter, etc.) has been stripped. Unlike CapturePane it takes no pane index — tmux resolves a bare "session:window" target to that window's active pane, which is exactly what the overview card represents. Works on NON-attached / background windows too (no switch).

It captures the whole VISIBLE screen (no -S), not just N lines: an agent's chrome is ~12 lines pinned to the bottom, so the content worth showing sits above it and would be lost if we grabbed only the last N raw lines first. Stripping (per `tool`) then capping keeps the pushed payload — and the diff that decides whether to push at all — as small as the const promises.

func (*TmuxProber) DetectAgentsInPanes

func (tp *TmuxProber) DetectAgentsInPanes(ctx context.Context, panes []TmuxPane) map[int]AgentTool

DetectAgentsInPanes returns a map of pane PID → AgentTool for panes with AI tools.

func (*TmuxProber) DetectTmux

func (tp *TmuxProber) DetectTmux(ctx context.Context, shellPID int) bool

DetectTmux checks if tmux client is running as a child of shellPID.

func (*TmuxProber) FindClientName

func (tp *TmuxProber) FindClientName(ctx context.Context, shellPID int) string

FindClientName returns the tmux client name (its tty, the handle `switch-client -c` wants) for the client in shellPID's child tree, "" when that shell is not attached to tmux.

func (*TmuxProber) FindClientSession

func (tp *TmuxProber) FindClientSession(ctx context.Context, shellPID int) string

FindClientSession finds which tmux session the CLI session's tmux client is attached to.

func (*TmuxProber) ListPanes

func (tp *TmuxProber) ListPanes(ctx context.Context) ([]TmuxPane, error)

ListPanes returns panes from the tmux server visible to this process.

func (*TmuxProber) ListPanesForSession

func (tp *TmuxProber) ListPanesForSession(ctx context.Context, sessionName string) ([]TmuxPane, error)

ListPanesForSession returns all panes in the given tmux session.

type TmuxSessionState

type TmuxSessionState struct {
	Name     string            `json:"name"`
	Attached bool              `json:"attached"`
	Windows  []TmuxWindowState `json:"windows"`
}

TmuxSessionState is one tmux session with its windows.

type TmuxState

type TmuxState struct {
	Installed     bool `json:"installed"`
	ServerRunning bool `json:"serverRunning"`
	Attached      bool `json:"attached"`
	// AttachedSession is the tmux session name this shellPID's client is attached
	// to (empty when not attached). It scopes the pane bar to THIS session's
	// windows rather than any session that merely has a client somewhere.
	AttachedSession string     `json:"attachedSession"`
	Prefix          TmuxPrefix `json:"prefix"`
	// ModeKeys is the resolved global `mode-keys` option ("vi" | "emacs"). It tells the
	// client which copy-mode key table is active, so a semantic copy-mode motion (e.g.
	// halfpage-up) can be mapped to the correct keystroke for THIS server — the SSOT for
	// "how to express copy-mode motions" shared by every connected client.
	ModeKeys string             `json:"modeKeys"`
	Sessions []TmuxSessionState `json:"sessions"`
}

TmuxState is the full tmux topology snapshot for a host process. It is designed to be cheap to recompute (~1s poll): prefix + installed are cached, and the topology comes from a single batched tmux format query plus one shared ps snapshot for per-pane agent detection.

type TmuxStateService

type TmuxStateService struct {
	// contains filtered or unexported fields
}

TmuxStateService aggregates tmux topology + agent detection with light caching. It is safe for concurrent use. A nil receiver is never valid — use NewTmuxStateService.

func NewTmuxStateService

func NewTmuxStateService() *TmuxStateService

NewTmuxStateService builds a service over the shared process inspector so it reuses the same ps snapshot as the rest of the package.

func (*TmuxStateService) Attached

func (s *TmuxStateService) Attached(ctx context.Context, shellPID int) bool

Attached reports whether the shell identified by shellPID is running inside a tmux client (i.e. a tmux client process exists in its descendant tree).

func (*TmuxStateService) CopyMotion

func (s *TmuxStateService) CopyMotion(ctx context.Context, session, motion string) error

CopyMotion runs a tmux copy-mode scroll command DIRECTLY against the server, bypassing the PTY keystroke stream.

Why not keystrokes: driving the motion through the byte stream (prefix `[` to enter copy-mode, then prefix `:` + `send-keys -X <motion>` via the command prompt) proved fragile and silently no-ops while in copy-mode — empirically the motion never applied. A raw key (vi C-u / emacs M-Up) instead depends on the live mode-keys AND the user not having rebound it. A server-side `send-keys -X <motion>` is identical for every config and always applies.

`send-keys -X` requires the pane to already be in a mode, so copy-mode is entered first ONLY when the pane is not already in one — re-entering would reset the scroll position to the bottom and defeat a scroll-up.

func (*TmuxStateService) ModeKeys

func (s *TmuxStateService) ModeKeys(ctx context.Context) string

ModeKeys returns the resolved global mode-keys ("vi" | "emacs"), cached with the same short TTL as the prefix. Falls back to "emacs" when tmux is absent or unreadable.

func (*TmuxStateService) NewSession

func (s *TmuxStateService) NewSession(ctx context.Context, shellPID int) (string, error)

NewSession creates a fresh DETACHED tmux session and switches the client that shellPID is attached through onto it, so the user lands directly in the new session.

Why server-side: driving `new-session` through the client's keystroke stream fails — the command prompt opens on `prefix :` but the rest of the burst arrives before the prompt is ready (same fragility as copy-motion), and `new-session` run inside a client where $TMUX is set refuses to nest. Running it here (tmuxCommandContext sanitizes $TMUX) avoids both: `-d` creates without attaching (no nesting), then switch-client moves the client. Returns the new session name. When the client can't be resolved (shell not attached) the session is still created and the ~1s topology poll surfaces it.

func (*TmuxStateService) Prefix

func (s *TmuxStateService) Prefix(ctx context.Context) TmuxPrefix

Prefix returns the resolved tmux prefix, cached with a short TTL. Falls back to C-b when tmux is absent or the option is unreadable.

func (*TmuxStateService) RefreshClient added in v0.6.0

func (s *TmuxStateService) RefreshClient(ctx context.Context, shellPID int) error

RefreshClient forces tmux to fully redraw the screen to the client attached through shellPID. The web UI calls this to resync when xterm.js's grid has DIVERGED from tmux's model — ghosting: stale glyphs from a previous frame that survive in xterm's BUFFER (not just its renderer), so a client-side repaint can't clear them. tmux's incremental client update occasionally leaves such residue under a fullscreen TUI's differential redraws; a server-side `refresh-client` resends every cell and clears it (proven: a manual refresh-client fixes the residue while term.refresh does not). No size change → no reflow flicker.

func (*TmuxStateService) SelectWindow added in v0.7.6

func (s *TmuxStateService) SelectWindow(ctx context.Context, shellPID, index int) error

SelectWindow switches the session that shellPID's client is attached to onto window `index`. Server-side, not a PTY keystroke: tmux binds prefix+0..9 to select-window, but index ≥10 has no binding, so the keystroke route must open the command prompt (prefix `:` + `select-window -t N` ⏎) — and the burst races the prompt open (same fragility as CopyMotion/NewSession), leaking the literal `select-window -t N` into the focused app. tmuxCommandContext targets the right socket; scoping `-t` to the client's own session keeps a multi-session server from switching the wrong one.

func (*TmuxStateService) ServerRunning

func (s *TmuxStateService) ServerRunning(ctx context.Context) bool

ServerRunning reports whether any tmux server is reachable for this process.

func (*TmuxStateService) SetOverviewActive added in v0.7.0

func (s *TmuxStateService) SetOverviewActive(v bool)

SetOverviewActive toggles per-window tail capture on/off — called when a client opens/closes the Agent Overview (via POST /tmux/overview). Off by default so tail costs nothing until asked.

func (*TmuxStateService) State

func (s *TmuxStateService) State(ctx context.Context, shellPID int) TmuxState

State builds the full TmuxState snapshot. shellPID (optional, 0 to skip) is used to compute the Attached flag for the calling session's shell.

It is non-blocking-friendly: every tmux/ps subprocess runs under a short context timeout, and a missing server degrades gracefully to an empty session list rather than an error.

func (*TmuxStateService) TmuxInstalled

func (s *TmuxStateService) TmuxInstalled() bool

TmuxInstalled reports whether the tmux binary is available, cached for 60s.

type TmuxWindowState

type TmuxWindowState struct {
	Index int    `json:"index"`
	Name  string `json:"name"`
	// WindowID is tmux's stable "@N" id — survives index reuse/reorder, unlike Index. The Agent
	// Overview keys its per-window seen-state on it so a reused index can't inherit stale state.
	WindowID string          `json:"windowId,omitempty"`
	Active   bool            `json:"active"`
	Panes    []TmuxPaneState `json:"panes"`
	// Tail is the last few lines of this window's active pane, for the Agent Overview's
	// per-window live preview. Optional: absent when capture failed or is disabled.
	Tail []string `json:"tail,omitempty"`
}

TmuxWindowState is one window with its panes.

type TurnMetrics

type TurnMetrics struct {
	TurnNumber        int    `json:"turn_number"`
	UserInput         string `json:"user_input"`
	DurationMs        int64  `json:"duration_ms"`
	TtftMs            *int64 `json:"ttft_ms"`
	InputTokens       *int   `json:"input_tokens"`
	OutputTokens      *int   `json:"output_tokens"`
	CacheReadTokens   *int   `json:"cache_read_tokens"`
	CacheCreateTokens *int   `json:"cache_create_tokens"`
	ToolCalls         *int   `json:"tool_calls"`
	OutputWindowMs    *int64 `json:"output_window_ms"`
}

TurnMetrics is one user→assistant cycle. A turn opens on a user text message and runs until the next user text message. Nullable pointer fields are nil when the turn produced no assistant activity (e.g. a trailing user prompt still in flight).

type UsageAccumulator

type UsageAccumulator struct {
	Totals UsageTotals
	// contains filtered or unexported fields
}

UsageAccumulator deduplicates and aggregates token usage across messages.

func NewUsageAccumulator

func NewUsageAccumulator() *UsageAccumulator

func (*UsageAccumulator) Ingest

func (ua *UsageAccumulator) Ingest(key CountedUsageKey, current UsageTotals) UsageTotals

Ingest processes a usage entry and returns the delta added to totals. new_stored[field] = max(prev[field], current[field]); delta = new_stored - prev; totals += delta.

type UsageTotals

type UsageTotals struct {
	InputTokens       int
	OutputTokens      int
	CacheReadTokens   int
	CacheCreateTokens int
	TotalTokens       int
}

UsageTotals holds token counts for a session or message.

type WaitReason

type WaitReason string

WaitReason differentiates why the agent is waiting.

const (
	WaitNone       WaitReason = ""
	WaitPrompt     WaitReason = "prompt"     // Waiting for next user prompt
	WaitPermission WaitReason = "permission" // Waiting for tool approval [Y/n]
	WaitQuestion   WaitReason = "question"   // Asking user a question
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL