diff --git a/README.md b/README.md
index 32bc35c..b1e4ada 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@

[](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)
}
}