diff --git a/README.md b/README.md index 32bc35c..b1e4ada 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![License: MIT](https://img.shields.io/badge/license-MIT-blue) [![Product Hunt](https://img.shields.io/badge/Product%20Hunt-Launch-ff6154?logo=producthunt&logoColor=white)](https://www.producthunt.com/products/lazy-agent) -A terminal UI, macOS menu bar app, and HTTP API for monitoring all your coding agents — [Claude Code](https://claude.ai/code), [Cursor](https://cursor.com/), [pi](https://github.com/badlogic/pi-mono), and [OpenCode](https://opencode.ai/) — from a single place. No lock-in, no server, purely observational. +A terminal UI, macOS menu bar app, and HTTP API for monitoring all your coding agents — [Claude Code](https://claude.ai/code), [Cursor](https://cursor.com/), [Codex](https://developers.openai.com/codex/), [pi](https://github.com/badlogic/pi-mono), and [OpenCode](https://opencode.ai/) — from a single place. No lock-in, no server, purely observational. Inspired by [lazygit](https://github.com/jesseduffield/lazygit), [lazyworktree](https://github.com/chmouel/lazyworktree), and [pixel-agents](https://github.com/pablodelucca/pixel-agents). @@ -33,10 +33,11 @@ lazyagent watches session data from coding agents to determine what each session - **Claude Code CLI** — reads JSONL from `~/.claude/projects/*/` - **Claude Code Desktop** — same JSONL files, enriched with session metadata (title, permissions) from `~/Library/Application Support/Claude/claude-code-sessions/` - **Cursor** — reads SQLite from `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` +- **Codex CLI** — reads JSONL from `~/.codex/sessions/YYYY/MM/DD/*.jsonl` - **pi coding agent** — reads JSONL from `~/.pi/agent/sessions/*/` - **OpenCode** — reads SQLite from `~/.local/share/opencode/opencode.db` -Use `--agent claude`, `--agent pi`, `--agent opencode`, `--agent cursor`, or `--agent all` (default) to control which agents are monitored. Agents can also be enabled/disabled in the config file. Pi sessions are marked with a **π** prefix, Cursor with **C**, OpenCode with **O**, and Desktop sessions with a **D** prefix in the session list. +Use `--agent claude`, `--agent pi`, `--agent opencode`, `--agent cursor`, `--agent codex`, or `--agent all` (default) to control which agents are monitored. Agents can also be enabled/disabled in the config file. Pi sessions are marked with a **π** prefix, Cursor with **C**, Codex with **X**, OpenCode with **O**, and Desktop sessions with a **D** prefix in the session list. From the JSONL stream it detects activity states with color-coded labels: @@ -119,6 +120,7 @@ lazyagent --agent claude Monitor only Claude Code sessions lazyagent --agent pi Monitor only pi coding agent sessions lazyagent --agent opencode Monitor only OpenCode sessions lazyagent --agent cursor Monitor only Cursor sessions +lazyagent --agent codex Monitor only Codex CLI sessions lazyagent --agent all Monitor all agents (default) lazyagent --api Start the HTTP API (http://127.0.0.1:7421) lazyagent --api --host :8080 Start the HTTP API on a custom address @@ -234,6 +236,7 @@ lazyagent reads `~/.config/lazyagent/config.json` (created automatically with de "notify_after_sec": 30, "agents": { "claude": true, + "codex": true, "cursor": true, "opencode": true, "pi": true @@ -260,9 +263,10 @@ lazyagent/ ├── main.go # Entry point: dispatches --tui / --tray / --api / --agent ├── internal/ │ ├── core/ # Shared: watcher, activity, session, config, helpers -│ │ └── provider.go # SessionProvider interface + Multi/Live/Pi/OpenCode/Cursor providers +│ │ └── provider.go # SessionProvider interface + Multi/Live/Pi/OpenCode/Cursor/Codex providers │ ├── model/ # Shared types (Session, ToolCall, etc.) │ ├── claude/ # Claude Code JSONL parsing, desktop metadata, session discovery +│ ├── codex/ # Codex CLI JSONL parsing and session discovery │ ├── cursor/ # Cursor IDE session discovery from state.vscdb │ ├── pi/ # pi coding agent JSONL parsing, session discovery │ ├── opencode/ # OpenCode SQLite parsing, session discovery diff --git a/frontend/src/lib/SessionDetail.svelte b/frontend/src/lib/SessionDetail.svelte index 8e1bc2a..6ca611f 100644 --- a/frontend/src/lib/SessionDetail.svelte +++ b/frontend/src/lib/SessionDetail.svelte @@ -119,7 +119,7 @@
{#if detail.agent}
Agent
-
{detail.agent === "pi" ? "π pi" : detail.agent}
+
{detail.agent === "pi" ? "π pi" : detail.agent === "codex" ? "X codex" : detail.agent}
{/if} {#if detail.source === "desktop"}
Source
@@ -135,6 +135,9 @@ {:else if detail.agent === "claude"}
Source
CLI
+ {:else if detail.agent === "codex"} +
Source
+
Codex CLI
{/if} {#if detail.model}
Model
diff --git a/frontend/src/lib/SessionList.svelte b/frontend/src/lib/SessionList.svelte index 4ace9ca..08b8db0 100644 --- a/frontend/src/lib/SessionList.svelte +++ b/frontend/src/lib/SessionList.svelte @@ -101,6 +101,7 @@ {#if session.agent === "pi"}π {:else if session.agent === "opencode"}O {:else if session.agent === "cursor"}C + {:else if session.agent === "codex"}X {:else if session.source === "desktop"}D {/if} {session.customName || session.agentName || session.shortName} diff --git a/internal/codex/process.go b/internal/codex/process.go new file mode 100644 index 0000000..36bd7e2 --- /dev/null +++ b/internal/codex/process.go @@ -0,0 +1,428 @@ +package codex + +import ( + "bufio" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/illegalstudio/lazyagent/internal/claude" + "github.com/illegalstudio/lazyagent/internal/model" +) + +// SessionsDir returns the path to Codex session JSONL files under ~/.codex/sessions. +func SessionsDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".codex", "sessions") +} + +// SessionIndexPath returns the path to Codex's thread-name index file. +func SessionIndexPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".codex", "session_index.jsonl") +} + +// DiscoverSessions scans the Codex sessions tree for JSONL session files. +func DiscoverSessions(cache *model.SessionCache) ([]*model.Session, error) { + return discoverSessionsFromDir(SessionsDir(), SessionIndexPath(), cache) +} + +func discoverSessionsFromDir(sessionsDir, indexPath string, cache *model.SessionCache) ([]*model.Session, error) { + if sessionsDir == "" { + return nil, fmt.Errorf("could not find home directory") + } + + names := loadSessionNames(indexPath) + wtCache := make(map[string]wtInfo) + seen := make(map[string]struct{}) + var sessions []*model.Session + + err := filepath.WalkDir(sessionsDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() || filepath.Ext(path) != ".jsonl" { + return nil + } + + seen[path] = struct{}{} + cached, offset, mtime := cache.GetIncremental(path) + + var session *model.Session + switch { + case cached != nil && offset == 0: + session = cached + case cached != nil && offset > 0: + s, newOffset, err := ParseJSONLIncremental(path, offset, cached) + if err != nil { + return nil + } + session = s + enrichSession(session, wtCache, names) + cache.Put(path, mtime, newOffset, session) + default: + s, size, err := ParseJSONL(path) + if err != nil { + return nil + } + session = s + enrichSession(session, wtCache, names) + cache.Put(path, mtime, size, session) + } + + sessions = append(sessions, session) + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("walk codex sessions: %w", err) + } + + cache.Prune(seen) + return sessions, nil +} + +type wtInfo struct { + isWorktree bool + mainRepo string +} + +func enrichSession(session *model.Session, wtCache map[string]wtInfo, names map[string]string) { + if session.SessionID != "" && session.Name == "" { + session.Name = names[session.SessionID] + } + if session.CWD == "" { + return + } + if _, ok := wtCache[session.CWD]; !ok { + isWT, mainRepo := claude.IsWorktree(session.CWD) + wtCache[session.CWD] = wtInfo{isWorktree: isWT, mainRepo: mainRepo} + } + wt := wtCache[session.CWD] + session.IsWorktree = wt.isWorktree + session.MainRepo = wt.mainRepo +} + +type indexEntry struct { + ID string `json:"id"` + ThreadName string `json:"thread_name"` +} + +func loadSessionNames(path string) map[string]string { + names := make(map[string]string) + f, err := os.Open(path) + if err != nil { + return names + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + var e indexEntry + if err := json.Unmarshal(scanner.Bytes(), &e); err != nil { + continue + } + if e.ID != "" && e.ThreadName != "" { + names[e.ID] = e.ThreadName + } + } + return names +} + +type jsonlEnvelope struct { + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type sessionMetaPayload struct { + ID string `json:"id"` + CWD string `json:"cwd"` + CLIVersion string `json:"cli_version"` + AgentNickname string `json:"agent_nickname"` + Source json.RawMessage `json:"source"` +} + +type turnContextPayload struct { + CWD string `json:"cwd"` + Model string `json:"model"` + Git gitCtx `json:"git"` +} + +type gitCtx struct { + Branch string `json:"branch"` +} + +type responseItemPayload struct { + Type string `json:"type"` + Name string `json:"name"` + Role string `json:"role"` + Arguments string `json:"arguments"` + Content []responseItemBlock `json:"content"` +} + +type responseItemBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type eventPayload struct { + Type string `json:"type"` + LastAgentMessage string `json:"last_agent_message"` + Info *tokenCountInfo `json:"info"` +} + +type tokenCountInfo struct { + TotalTokenUsage tokenUsage `json:"total_token_usage"` +} + +type tokenUsage struct { + InputTokens int `json:"input_tokens"` + CachedInputTokens int `json:"cached_input_tokens"` + OutputTokens int `json:"output_tokens"` + ReasoningOutput int `json:"reasoning_output_tokens"` +} + +type lastMeaningful struct { + Kind string + Timestamp time.Time + ToolName string +} + +// ParseJSONL reads a Codex session file and builds a Session snapshot. +func ParseJSONL(path string) (*model.Session, int64, error) { + return parseJSONL(path, 0, nil) +} + +// ParseJSONLIncremental reads only new lines and merges them into a prior session. +func ParseJSONLIncremental(path string, offset int64, base *model.Session) (*model.Session, int64, error) { + return parseJSONL(path, offset, base) +} + +func parseJSONL(path string, offset int64, base *model.Session) (*model.Session, int64, error) { + f, err := os.Open(path) + if err != nil { + return nil, 0, err + } + defer f.Close() + + if offset > 0 { + if _, err := f.Seek(offset, 0); err != nil { + return nil, 0, err + } + } + + var session *model.Session + if base != nil { + session = base.Clone() + session.JSONLPath = path + } else { + session = &model.Session{ + JSONLPath: path, + Agent: "codex", + } + } + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + bytesConsumed := offset + var last lastMeaningful + if base != nil { + last = lastMeaningful{Kind: statusKind(base.Status), Timestamp: base.LastActivity, ToolName: base.CurrentTool} + } + + for scanner.Scan() { + bytesConsumed += int64(len(scanner.Bytes())) + 1 + + var env jsonlEnvelope + if err := json.Unmarshal(scanner.Bytes(), &env); err != nil { + continue + } + + ts, _ := time.Parse(time.RFC3339Nano, env.Timestamp) + if !ts.IsZero() { + session.EntryTimestamps = append(session.EntryTimestamps, ts) + if len(session.EntryTimestamps) > 500 { + session.EntryTimestamps = session.EntryTimestamps[len(session.EntryTimestamps)-500:] + } + } + + switch env.Type { + case "session_meta": + var meta sessionMetaPayload + if err := json.Unmarshal(env.Payload, &meta); err != nil { + continue + } + if meta.ID != "" { + session.SessionID = meta.ID + } + if meta.CWD != "" { + session.CWD = meta.CWD + } + if meta.CLIVersion != "" { + session.Version = meta.CLIVersion + } + if meta.AgentNickname != "" || strings.Contains(string(meta.Source), "\"subagent\"") { + session.IsSidechain = true + } + case "turn_context": + var ctx turnContextPayload + if err := json.Unmarshal(env.Payload, &ctx); err != nil { + continue + } + if ctx.CWD != "" { + session.CWD = ctx.CWD + } + if ctx.Model != "" { + session.Model = ctx.Model + } + if ctx.Git.Branch != "" { + session.GitBranch = ctx.Git.Branch + } + case "response_item": + var item responseItemPayload + if err := json.Unmarshal(env.Payload, &item); err != nil { + continue + } + switch item.Type { + case "message": + text := strings.TrimSpace(joinItemText(item.Content)) + switch item.Role { + case "user": + session.UserMessages++ + if text != "" { + appendMessage(session, "user", text, ts) + } + last = lastMeaningful{Kind: "user", Timestamp: ts} + case "assistant": + session.AssistantMessages++ + if text != "" { + appendMessage(session, "assistant", text, ts) + } + last = lastMeaningful{Kind: "assistant", Timestamp: ts} + } + case "function_call": + appendTool(session, item.Name, ts) + last = lastMeaningful{Kind: "tool", Timestamp: ts, ToolName: item.Name} + if item.Name == "apply_patch" { + session.LastFileWrite = "apply_patch" + session.LastFileWriteAt = ts + } + case "function_call_output": + last = lastMeaningful{Kind: "tool_output", Timestamp: ts} + } + case "event_msg": + var event eventPayload + if err := json.Unmarshal(env.Payload, &event); err != nil { + continue + } + switch event.Type { + case "user_message": + last = lastMeaningful{Kind: "user", Timestamp: ts} + case "agent_message": + last = lastMeaningful{Kind: "assistant", Timestamp: ts} + case "token_count": + if event.Info != nil { + session.InputTokens = event.Info.TotalTokenUsage.InputTokens + session.CacheReadTokens = event.Info.TotalTokenUsage.CachedInputTokens + session.OutputTokens = event.Info.TotalTokenUsage.OutputTokens + event.Info.TotalTokenUsage.ReasoningOutput + } + case "task_complete": + if strings.TrimSpace(event.LastAgentMessage) != "" { + last = lastMeaningful{Kind: "assistant", Timestamp: ts} + } + } + } + } + + session.TotalMessages = session.UserMessages + session.AssistantMessages + session.Status = statusFromKind(last.Kind) + session.CurrentTool = "" + if session.Status == model.StatusExecutingTool { + session.CurrentTool = last.ToolName + } + if !last.Timestamp.IsZero() { + session.LastActivity = last.Timestamp + } + + if fi, err := f.Stat(); err == nil && bytesConsumed > fi.Size() { + bytesConsumed = fi.Size() + } + + return session, bytesConsumed, nil +} + +func appendTool(session *model.Session, name string, ts time.Time) { + if name == "" { + return + } + session.RecentTools = append(session.RecentTools, model.ToolCall{Name: name, Timestamp: ts}) + if len(session.RecentTools) > 20 { + session.RecentTools = session.RecentTools[len(session.RecentTools)-20:] + } +} + +func appendMessage(session *model.Session, role, text string, ts time.Time) { + session.RecentMessages = append(session.RecentMessages, model.ConversationMessage{ + Role: role, + Text: model.Truncate(text, 300), + Timestamp: ts, + }) + if len(session.RecentMessages) > 10 { + session.RecentMessages = session.RecentMessages[len(session.RecentMessages)-10:] + } +} + +func joinItemText(content []responseItemBlock) string { + var parts []string + for _, block := range content { + if (block.Type == "input_text" || block.Type == "output_text") && block.Text != "" { + parts = append(parts, block.Text) + } + } + return strings.Join(parts, "\n") +} + +func statusKind(status model.SessionStatus) string { + switch status { + case model.StatusWaitingForUser: + return "assistant" + case model.StatusThinking: + return "user" + case model.StatusExecutingTool: + return "tool" + case model.StatusProcessingResult: + return "tool_output" + default: + return "" + } +} + +func statusFromKind(kind string) model.SessionStatus { + switch kind { + case "assistant": + return model.StatusWaitingForUser + case "user": + return model.StatusThinking + case "tool": + return model.StatusExecutingTool + case "tool_output": + return model.StatusProcessingResult + default: + return model.StatusIdle + } +} diff --git a/internal/codex/process_test.go b/internal/codex/process_test.go new file mode 100644 index 0000000..b872f12 --- /dev/null +++ b/internal/codex/process_test.go @@ -0,0 +1,136 @@ +package codex + +import ( + "os" + "path/filepath" + "testing" + + "github.com/illegalstudio/lazyagent/internal/model" +) + +func TestSessionsDir(t *testing.T) { + dir := SessionsDir() + if dir == "" { + t.Fatal("SessionsDir() returned empty string") + } + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".codex", "sessions") + if dir != want { + t.Fatalf("SessionsDir() = %q, want %q", dir, want) + } +} + +func TestDiscoverSessions_FromSyntheticDir(t *testing.T) { + dir := t.TempDir() + dayDir := filepath.Join(dir, "2026", "03", "28") + if err := os.MkdirAll(dayDir, 0o755); err != nil { + t.Fatal(err) + } + + sessionID := "019d3431-8669-7603-be71-7079fa555f4a" + indexPath := filepath.Join(t.TempDir(), "session_index.jsonl") + index := `{"id":"` + sessionID + `","thread_name":"Add Codex support"}` + if err := os.WriteFile(indexPath, []byte(index+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + content := `{"timestamp":"2026-03-28T11:26:17.785Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"/tmp/project","cli_version":"0.116.0","source":"cli"}} +{"timestamp":"2026-03-28T11:26:17.900Z","type":"turn_context","payload":{"cwd":"/tmp/project","model":"gpt-5.2-codex","git":{"branch":"main"}}} +{"timestamp":"2026-03-28T11:26:18.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"please add codex"}]}} +{"timestamp":"2026-03-28T11:26:19.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"rg codex\"}"}} +{"timestamp":"2026-03-28T11:26:20.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"ok"}} +{"timestamp":"2026-03-28T11:26:21.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1200,"cached_input_tokens":300,"output_tokens":500,"reasoning_output_tokens":100}}}} +{"timestamp":"2026-03-28T11:26:22.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"implemented"}]}} +` + path := filepath.Join(dayDir, "rollout-2026-03-28T11-25-54-"+sessionID+".jsonl") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + sessions, err := discoverSessionsFromDir(dir, indexPath, model.NewSessionCache()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("got %d sessions, want 1", len(sessions)) + } + + got := sessions[0] + if got.SessionID != sessionID { + t.Fatalf("SessionID = %q, want %q", got.SessionID, sessionID) + } + if got.Name != "Add Codex support" { + t.Fatalf("Name = %q, want %q", got.Name, "Add Codex support") + } + if got.Agent != "codex" { + t.Fatalf("Agent = %q, want codex", got.Agent) + } + if got.Model != "gpt-5.2-codex" { + t.Fatalf("Model = %q, want gpt-5.2-codex", got.Model) + } + if got.GitBranch != "main" { + t.Fatalf("GitBranch = %q, want main", got.GitBranch) + } + if got.UserMessages != 1 || got.AssistantMessages != 1 || got.TotalMessages != 2 { + t.Fatalf("message counts = (%d,%d,%d), want (1,1,2)", got.UserMessages, got.AssistantMessages, got.TotalMessages) + } + if got.Status != model.StatusWaitingForUser { + t.Fatalf("Status = %v, want waiting", got.Status) + } + if len(got.RecentTools) != 1 || got.RecentTools[0].Name != "exec_command" { + t.Fatalf("RecentTools = %#v, want exec_command", got.RecentTools) + } + if got.InputTokens != 1200 || got.CacheReadTokens != 300 || got.OutputTokens != 600 { + t.Fatalf("tokens = (%d,%d,%d), want (1200,300,600)", got.InputTokens, got.CacheReadTokens, got.OutputTokens) + } +} + +func TestParseJSONLIncremental(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.jsonl") + + initial := `{"timestamp":"2026-03-28T11:26:17.785Z","type":"session_meta","payload":{"id":"s1","cwd":"/tmp/project","cli_version":"0.116.0","source":"cli"}} +{"timestamp":"2026-03-28T11:26:18.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}} +{"timestamp":"2026-03-28T11:26:19.000Z","type":"response_item","payload":{"type":"function_call","name":"apply_patch","arguments":"*** Begin Patch"}} +` + if err := os.WriteFile(path, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + base, offset, err := ParseJSONL(path) + if err != nil { + t.Fatalf("ParseJSONL error: %v", err) + } + if base.Status != model.StatusExecutingTool || base.CurrentTool != "apply_patch" { + t.Fatalf("base status/tool = (%v,%q), want executing/apply_patch", base.Status, base.CurrentTool) + } + + more := `{"timestamp":"2026-03-28T11:26:20.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"ok"}} +{"timestamp":"2026-03-28T11:26:21.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}} +` + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(more); err != nil { + t.Fatal(err) + } + _ = f.Close() + + got, _, err := ParseJSONLIncremental(path, offset, base) + if err != nil { + t.Fatalf("ParseJSONLIncremental error: %v", err) + } + if got.Status != model.StatusWaitingForUser { + t.Fatalf("Status = %v, want waiting", got.Status) + } + if got.CurrentTool != "" { + t.Fatalf("CurrentTool = %q, want empty", got.CurrentTool) + } + if got.LastFileWrite != "apply_patch" { + t.Fatalf("LastFileWrite = %q, want apply_patch", got.LastFileWrite) + } + if got.TotalMessages != 2 { + t.Fatalf("TotalMessages = %d, want 2", got.TotalMessages) + } +} diff --git a/internal/core/activity.go b/internal/core/activity.go index af2c079..f76d2cb 100644 --- a/internal/core/activity.go +++ b/internal/core/activity.go @@ -150,10 +150,18 @@ func ToolActivity(tool string) ActivityKind { return ActivityRunning case "find", "lsp": return ActivitySearching - case "web_search": - return ActivityBrowsing case "subagent", "task": return ActivitySpawning + case "exec_command", "write_stdin": + return ActivityRunning + case "apply_patch": + return ActivityWriting + case "view_image": + return ActivityReading + case "web_search", "search_query", "image_query", "open", "click": + return ActivityBrowsing + case "spawn_agent": + return ActivitySpawning default: if tool != "" { return ActivityRunning diff --git a/internal/core/config.go b/internal/core/config.go index 295433e..1aac2a7 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -32,6 +32,7 @@ func DefaultConfig() Config { "pi": true, "opencode": true, "cursor": true, + "codex": true, }, } } diff --git a/internal/core/provider.go b/internal/core/provider.go index 3fc25d0..66d5354 100644 --- a/internal/core/provider.go +++ b/internal/core/provider.go @@ -4,6 +4,7 @@ import ( "time" "github.com/illegalstudio/lazyagent/internal/claude" + "github.com/illegalstudio/lazyagent/internal/codex" "github.com/illegalstudio/lazyagent/internal/cursor" "github.com/illegalstudio/lazyagent/internal/model" "github.com/illegalstudio/lazyagent/internal/opencode" @@ -106,6 +107,29 @@ func (p *CursorProvider) WatchDirs() []string { return nil } +// CodexProvider discovers Codex CLI sessions from JSONL transcripts. +type CodexProvider struct { + cache *model.SessionCache +} + +// NewCodexProvider creates a CodexProvider. +func NewCodexProvider() *CodexProvider { + return &CodexProvider{cache: model.NewSessionCache()} +} + +func (p *CodexProvider) DiscoverSessions() ([]*model.Session, error) { + return codex.DiscoverSessions(p.cache) +} + +func (p *CodexProvider) UseWatcher() bool { return false } +func (p *CodexProvider) RefreshInterval() time.Duration { return 3 * time.Second } +func (p *CodexProvider) WatchDirs() []string { + if d := codex.SessionsDir(); d != "" { + return []string{d} + } + return nil +} + // BuildProvider creates a SessionProvider based on agent mode and config. // When agentMode is "all", it reads the agents config to decide which providers // to include. A specific agentMode (e.g. "claude") overrides the config. @@ -119,6 +143,8 @@ func BuildProvider(agentMode string, cfg Config) SessionProvider { return NewOpenCodeProvider() case "cursor": return NewCursorProvider() + case "codex": + return NewCodexProvider() default: // "all" var providers []SessionProvider if cfg.AgentEnabled("claude") { @@ -133,6 +159,9 @@ func BuildProvider(agentMode string, cfg Config) SessionProvider { if cfg.AgentEnabled("cursor") { providers = append(providers, NewCursorProvider()) } + if cfg.AgentEnabled("codex") { + providers = append(providers, NewCodexProvider()) + } if len(providers) == 0 { // All disabled — return a no-op provider. return MultiProvider{} diff --git a/internal/model/types.go b/internal/model/types.go index e3381ee..ec00c57 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -43,8 +43,8 @@ type ToolCall struct { // ConversationMessage holds a single human-readable message from the conversation. type ConversationMessage struct { - Role string // "user" or "assistant" - Text string // first text block, truncated to 300 chars + Role string // "user" or "assistant" + Text string // first text block, truncated to 300 chars Timestamp time.Time } @@ -95,7 +95,7 @@ type Session struct { CacheReadTokens int // Agent identity - Agent string // "claude" or "pi" — which coding agent produced this session + Agent string // e.g. "claude", "pi", "codex" — which coding agent produced this session Name string // session display name (from pi session_info or custom) // Desktop metadata (non-nil if session was started via Claude Desktop) diff --git a/main.go b/main.go index 620ba3c..94b2f85 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,7 @@ func main() { apiMode := flag.Bool("api", false, "Start the API server") apiHost := flag.String("host", "", "API listen address (e.g. :7421 or 0.0.0.0:7421). Default: 127.0.0.1:7421") demoMode := flag.Bool("demo", false, "Use generated fake data instead of real Claude sessions") - agentMode := flag.String("agent", "all", "Which agent sessions to show: claude, pi, opencode, cursor, all (default: all)") + agentMode := flag.String("agent", "all", "Which agent sessions to show: claude, pi, opencode, cursor, codex, all (default: all)") flag.Usage = func() { fmt.Fprintf(os.Stderr, `%s — monitor all running coding agent sessions @@ -41,6 +41,7 @@ Usage: lazyagent --agent pi Monitor only pi coding agent sessions lazyagent --agent opencode Monitor only OpenCode sessions lazyagent --agent cursor Monitor only Cursor sessions + lazyagent --agent codex Monitor only Codex CLI sessions lazyagent --agent all Monitor all agents (default) lazyagent --api Start the API server (http://127.0.0.1:7421) lazyagent --api --host :7421 Start the API server on custom address @@ -81,10 +82,10 @@ If you find lazyagent useful, leave a ⭐ → https://github.com/illegalstudio/l provider = demo.Provider{} } else { switch *agentMode { - case "claude", "pi", "opencode", "cursor", "all": + case "claude", "pi", "opencode", "cursor", "codex", "all": provider = core.BuildProvider(*agentMode, cfg) default: - fmt.Fprintf(os.Stderr, "Error: unknown --agent value %q (use claude, pi, opencode, cursor, or all)\n", *agentMode) + fmt.Fprintf(os.Stderr, "Error: unknown --agent value %q (use claude, pi, opencode, cursor, codex, or all)\n", *agentMode) os.Exit(1) } }