Documentation
¶
Index ¶
- Constants
- Variables
- func CodexNewestRolloutForCWD(pl *ProjectLocator, cwd string) string
- func CodexRolloutExistsForCWD(pl *ProjectLocator, cwd string) bool
- func LogStateResolved(ctx context.Context, sessionID, mode string, state AgentState, ...)
- func LogTmuxScanComplete(ctx context.Context, sessionID, sessionName string, panes, agents int)
- type AgentIntelMonitorManager
- type AgentIntelResponse
- type AgentSnapshotWatcher
- type AgentState
- type AgentStateResolver
- type AgentStateWatcher
- type AgentStatus
- type AgentTool
- type ClaudeDriver
- type ClaudeSessionState
- type CodexDriver
- type CodexSessionState
- type CountedUsageKey
- type JSONLReader
- type PTYIdleSource
- type PaneAgentMonitor
- func (m *PaneAgentMonitor) Active(key, cwd string, tool AgentTool) bool
- func (m *PaneAgentMonitor) Awaiting(key, cwd string, tool AgentTool) bool
- func (m *PaneAgentMonitor) AwaitingSince(key string) time.Time
- func (m *PaneAgentMonitor) Prune(keep map[string]bool)
- func (m *PaneAgentMonitor) Status(key, cwd string, tool AgentTool) (AgentStatus, bool)
- type PriceJSON
- type ProcessInfo
- type ProcessInspector
- type ProjectLocator
- func (pl *ProjectLocator) ClaudeAllSessionFiles() []string
- func (pl *ProjectLocator) ClaudeProjectDir(projectPath string) string
- func (pl *ProjectLocator) ClaudeProjectsRoot() string
- func (pl *ProjectLocator) ClaudeSessionFiles(projectPath string) ([]string, error)
- func (pl *ProjectLocator) CodexLatestSession(projectPath string) (string, error)
- func (pl *ProjectLocator) CodexSessionDir() string
- func (pl *ProjectLocator) CodexSessionFiles() []string
- type PromptState
- type RecentFile
- type SessionActivity
- type SessionActivityGetter
- type SessionDetail
- type SessionMetrics
- type SessionSummary
- type TmuxPane
- type TmuxPaneState
- type TmuxPrefix
- type TmuxProber
- func (tp *TmuxProber) CapturePane(ctx context.Context, sessionWindow string, paneIdx, lines int) ([]string, error)
- func (tp *TmuxProber) CaptureWindowTail(ctx context.Context, sessionWindow string, tool AgentTool, lines int) ([]string, error)
- func (tp *TmuxProber) DetectAgentsInPanes(ctx context.Context, panes []TmuxPane) map[int]AgentTool
- func (tp *TmuxProber) DetectTmux(ctx context.Context, shellPID int) bool
- func (tp *TmuxProber) FindClientName(ctx context.Context, shellPID int) string
- func (tp *TmuxProber) FindClientSession(ctx context.Context, shellPID int) string
- func (tp *TmuxProber) ListPanes(ctx context.Context) ([]TmuxPane, error)
- func (tp *TmuxProber) ListPanesForSession(ctx context.Context, sessionName string) ([]TmuxPane, error)
- type TmuxSessionState
- type TmuxState
- type TmuxStateService
- func (s *TmuxStateService) Attached(ctx context.Context, shellPID int) bool
- func (s *TmuxStateService) CopyMotion(ctx context.Context, session, motion string) error
- func (s *TmuxStateService) ModeKeys(ctx context.Context) string
- func (s *TmuxStateService) NewSession(ctx context.Context, shellPID int) (string, error)
- func (s *TmuxStateService) Prefix(ctx context.Context) TmuxPrefix
- func (s *TmuxStateService) RefreshClient(ctx context.Context, shellPID int) error
- func (s *TmuxStateService) SelectWindow(ctx context.Context, shellPID, index int) error
- func (s *TmuxStateService) ServerRunning(ctx context.Context) bool
- func (s *TmuxStateService) SetOverviewActive(v bool)
- func (s *TmuxStateService) State(ctx context.Context, shellPID int) TmuxState
- func (s *TmuxStateService) TmuxInstalled() bool
- type TmuxWindowState
- type TurnMetrics
- type UsageAccumulator
- type UsageTotals
- type WaitReason
Constants ¶
const ( StgAgentDetect = "agent-intel/detect" StgAgentResult = "agent-intel/result" StgAgentStream = "agent-intel/stream" )
STG constants for agent-intel operations.
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 ¶
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.
var Logger = obs.Module("agent-intel")
Logger is the obs-native logger for agent-intel, exported for routes to use.
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.
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 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 ¶
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
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 ¶
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 ¶
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 ¶
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 ¶
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 )
Source Files
¶
- agent_state.go
- claude_driver.go
- codex_driver.go
- codex_metrics.go
- copy_motion.go
- counted_usage.go
- jsonl_reader.go
- observe.go
- output_analyzer.go
- pane_monitor.go
- process_inspector.go
- project_locator.go
- pty_idle_source.go
- recent_files.go
- resolver.go
- session_metrics.go
- snapshot_watcher.go
- tail_strip.go
- tmux_prober.go
- tmux_state.go
- watcher.go
- watcher_manager.go