Skip to content

Commit 5e1adb5

Browse files
authored
Merge pull request #73 from boxp/feature/boxp-66-session-file-watcher
[codex] Add session history file watcher
2 parents 5084c5b + acad130 commit 5e1adb5

12 files changed

Lines changed: 885 additions & 33 deletions

File tree

‎README.ja.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ ceeker
9292
**機能:**
9393

9494
- **自動反映**: `sessions.edn` のファイル変更を inotify(Linux)/ WatchService で検知し、TUIを自動更新
95+
- **session履歴ファイルwatcher**: hookなしでも Claude Code / Codex の JSONL 履歴ファイルからセッションを自動検知
9596
- **セッション絞り込み**: エージェント種別・ステータス・テキスト検索で表示を絞り込み
9697

9798
**キーバインド:**
@@ -133,7 +134,7 @@ ceeker --list-sessions
133134
]
134135
```
135136

136-
出力前に ceeker は 1 回だけ同期的に pane 生存確認と capture ベースの状態更新を行います。tmux 更新に失敗した場合でも、保存済みの session list はそのまま返します。
137+
出力前に ceeker は直近の session 履歴ファイルをスキャンし、その後 1 回だけ同期的に pane 生存確認と capture ベースの状態更新を行います。tmux 更新に失敗した場合でも、保存済みの session list はそのまま返します。
137138

138139
### ジャンプ後に自動終了
139140

@@ -161,9 +162,16 @@ tmux 内のどこからでもポップアップで ceeker を開けます。`--e
161162
bind-key C-k display-popup -h 80% -w 80% -d "#{pane_current_path}" -E "ceeker --exit-on-jump"
162163
```
163164

164-
## セットアップ(必須)
165+
## セットアップ
165166

166-
インストール後、AIコーディングエージェントからセッションイベントを受信するために hook の設定が**必要**です。
167+
ceeker は Claude Code / Codex の session 履歴 JSONL ファイルを監視して、セッションを自動検知します:
168+
169+
- Claude Code: `~/.claude/projects/<cwd-slug>/<session-id>.jsonl`
170+
- Codex: `~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl`
171+
172+
このため、hook 設定なしでも ceeker にセッションが表示されます。Codex は session ファイルだけで追跡できるため、`notify` 設定は任意です。
173+
174+
hook は、より細かいイベントタイミングや最終メッセージを取り込みたい場合に有用です。特に Claude Code の明示的な hook イベントや Codex notify 更新を併用できます。
167175

168176
### Claude Code
169177

‎README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Displays a list of all active sessions.
9292
**Features:**
9393

9494
- **Auto-refresh**: Detects file changes to `sessions.edn` via inotify (Linux) / WatchService and automatically updates the TUI
95+
- **Session file watcher**: Detects Claude Code / Codex sessions from history JSONL files even without hooks
9596
- **Session filtering**: Filter the display by agent type, status, or text search
9697

9798
**Key bindings:**
@@ -133,7 +134,7 @@ Example output:
133134
]
134135
```
135136

136-
Before printing, ceeker performs one synchronous pane liveness and capture-based state refresh. If tmux refresh fails, ceeker still returns the stored session list.
137+
Before printing, ceeker scans recent session history files, then performs one synchronous pane liveness and capture-based state refresh. If tmux refresh fails, ceeker still returns the stored session list.
137138

138139
### Exit on Jump
139140

@@ -161,9 +162,16 @@ You can open ceeker as a popup from anywhere inside tmux. Combine with `--exit-o
161162
bind-key C-k display-popup -h 80% -w 80% -d "#{pane_current_path}" -E "ceeker --exit-on-jump"
162163
```
163164

164-
## Setup (Required)
165+
## Setup
165166

166-
After installation, you **must** configure hooks so that ceeker can receive session events from your AI coding agents.
167+
ceeker automatically detects Claude Code and Codex sessions by watching their session history JSONL files:
168+
169+
- Claude Code: `~/.claude/projects/<cwd-slug>/<session-id>.jsonl`
170+
- Codex: `~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl`
171+
172+
This means sessions can appear in ceeker without hook configuration. Codex sessions are tracked from the session file alone, so `notify` is optional.
173+
174+
Hooks are still useful for richer event timing and final messages, especially if you want explicit Claude Code hook events or Codex notify updates.
167175

168176
### Claude Code
169177

‎docs/project_docs/BOXP-66/plan.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# BOXP-66 Session File Watcher
2+
3+
## Goal
4+
5+
Detect Claude Code and Codex sessions from their JSONL session history files so ceeker can show sessions before hook/notify events arrive.
6+
7+
## Design Decisions
8+
9+
- Added `ceeker.watch.sessions` as the single owner of session history parsing, incremental tailing, one-shot scans, pane-id resolution, and WatchService polling.
10+
- Kept WatchService and file reads inside `async/thread` via `start-session-watcher!`; the stop channel is unbuffered and is closed by the caller.
11+
- Used in-memory file offsets for incremental tailing. Each JSONL line is capped at 1 MiB; invalid or oversized lines are ignored.
12+
- One-shot `scan-recent-sessions!` scans JSONL files modified within the last 24 hours by default. It is called by TUI startup and `--list-sessions` before pane/capture refresh.
13+
- Codex `session_meta` establishes `session-id` and `cwd`; later `event_msg` lines update the accumulated per-file session state. `task_complete` maps to `:completed` and stores `last_agent_message`.
14+
- Claude lines with `sessionId`, `cwd`, or `timestamp` map to `:running`; assistant lines also record message content when present.
15+
- Pane-id resolution reuses `ceeker.tmux.pane/list-pane-info` and the new `find-agent-pid-in-tree`. On Linux it reads `/proc/<pid>/environ` for `TMUX_PANE`; otherwise it only falls back when exactly one cwd/agent candidate exists.
16+
- Store normalization now deduplicates entries with the same `:session-id`, preferring pane-id keyed entries and otherwise the newest `:last-updated`.
17+
- Watcher writes skip older same-session data so a stale file event does not overwrite newer hook/capture state.
18+
19+
## Verification
20+
21+
- Parser tests cover Claude and Codex JSONL events.
22+
- Tail tests cover offset-based append reads.
23+
- Pane resolution tests mock tmux and `/proc` access.
24+
- Store tests cover session-id deduplication.
25+
- Scan tests use temporary fixture directories.
26+
- Worker tests verify stop-channel shutdown.
27+
28+
## Review Fix Plan
29+
30+
- Add regression coverage for Claude assistant JSONL lines so `:last-message` remains available after scan, including when a following non-message event arrives.
31+
- Add regression coverage for synchronous scans when a JSONL file disappears between enumeration and opening; the scan should warn on stderr and continue.
32+
- Update session event merging to preserve incoming `:last-message` for any status and keep the accumulated value when later events omit it.
33+
- Wrap per-file scan reads in `try`/`catch` so unreadable files are skipped without aborting startup or `--list-sessions`.
34+
- Run `make ci` and commit the fix in English.

‎src/ceeker/session_list.clj‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
(ns ceeker.session-list
22
"Shared session list access for TUI and CLI."
33
(:require [ceeker.state.store :as store]
4-
[ceeker.tmux.pane :as pane]))
4+
[ceeker.tmux.pane :as pane]
5+
[ceeker.watch.sessions :as session-watch]))
56

67
(defn sort-sessions
78
"Sorts sessions the same way as the TUI list."
@@ -24,6 +25,7 @@
2425
(defn refresh-session-state!
2526
"Refreshes pane liveness and capture-based session states once."
2627
[state-dir]
28+
(session-watch/scan-recent-sessions! {:state-dir state-dir})
2729
(pane/close-stale-sessions! state-dir)
2830
(pane/refresh-session-states! state-dir))
2931

‎src/ceeker/state/store.clj‎

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,25 +101,85 @@
101101
incoming
102102
existing))
103103

104+
(defn- pane-key?
105+
"Returns true when key is the canonical pane key for session."
106+
[key session]
107+
(and (seq (:pane-id session))
108+
(= key (:pane-id session))))
109+
110+
(defn- pick-session-id-duplicate
111+
"Picks one entry among entries with the same :session-id.
112+
A pane-id keyed entry is preferred because pane-id is the
113+
canonical live-session key. Otherwise the newest entry wins."
114+
[[existing-key existing :as existing-entry]
115+
[incoming-key incoming :as incoming-entry]]
116+
(let [existing-pane? (pane-key? existing-key existing)
117+
incoming-pane? (pane-key? incoming-key incoming)]
118+
(cond
119+
(and incoming-pane? (not existing-pane?))
120+
incoming-entry
121+
(and existing-pane? (not incoming-pane?))
122+
existing-entry
123+
:else
124+
(if (= incoming (pick-newer existing incoming))
125+
incoming-entry
126+
existing-entry))))
127+
128+
(defn- canonicalize-by-pane [sessions]
129+
(reduce-kv
130+
(fn [acc key session]
131+
(let [pane-id (:pane-id session)
132+
canonical (if (seq pane-id) pane-id key)]
133+
(if-let [existing (get acc canonical)]
134+
(assoc acc canonical
135+
(pick-newer existing session))
136+
(assoc acc canonical session))))
137+
{}
138+
sessions))
139+
140+
(defn- session-id-winners [sessions]
141+
(reduce-kv
142+
(fn [acc key session]
143+
(if-let [session-id (:session-id session)]
144+
(if-let [existing-entry (get acc session-id)]
145+
(assoc acc session-id
146+
(pick-session-id-duplicate
147+
existing-entry [key session]))
148+
(assoc acc session-id [key session]))
149+
acc))
150+
{}
151+
sessions))
152+
153+
(defn- session-id-keys [sessions winners]
154+
(into #{}
155+
(map first)
156+
(filter (fn [[_key session]]
157+
(contains? winners
158+
(:session-id session)))
159+
sessions)))
160+
104161
(defn normalize-sessions
105162
"Re-keys sessions by :pane-id (canonical key).
106163
Entries with non-empty :pane-id are keyed by pane-id;
107164
entries without :pane-id keep their original key.
108165
When multiple entries share the same pane-id, the one
109-
with the latest :last-updated wins.
166+
with the latest :last-updated wins. When multiple entries
167+
share the same :session-id, they are deduplicated and
168+
pane-id keyed entries are preferred.
110169
Returns empty map when sessions is nil."
111170
[sessions]
112171
(if (nil? sessions)
113172
{}
114-
(reduce-kv
115-
(fn [acc key session]
116-
(let [pane-id (:pane-id session)
117-
canonical (if (seq pane-id) pane-id key)]
118-
(if-let [existing (get acc canonical)]
119-
(assoc acc canonical (pick-newer existing session))
120-
(assoc acc canonical session))))
121-
{}
122-
sessions)))
173+
(let [by-pane
174+
(canonicalize-by-pane sessions)
175+
by-session (session-id-winners by-pane)
176+
duplicate-keys (session-id-keys by-pane by-session)]
177+
(merge
178+
(apply dissoc by-pane duplicate-keys)
179+
(into {}
180+
(map (fn [[_session-id [key session]]]
181+
[key session]))
182+
by-session)))))
123183

124184
(defn- read-state-file
125185
"Reads and parses the sessions.edn file.

‎src/ceeker/tmux/pane.clj‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@
8282
(process-handle-command-line pid))))
8383
(catch Exception _ nil)))
8484

85+
(defn- pgrep-child-pids
86+
"Returns direct child PIDs via pgrep, or nil on failure."
87+
[pid]
88+
(try
89+
(let [result (shell/sh "pgrep" "-P" (str pid))]
90+
(if (zero? (:exit result))
91+
(remove str/blank?
92+
(str/split-lines
93+
(:out result)))
94+
()))
95+
(catch Exception _ nil)))
96+
8597
(defn- child-pids
8698
"Returns direct child PIDs of the given pid via /proc.
8799
Falls back to pgrep on non-Linux. Returns empty list
@@ -91,15 +103,12 @@
91103
(let [f (io/file (str "/proc/" pid "/task/"
92104
pid "/children"))]
93105
(if (.exists f)
94-
(remove str/blank?
95-
(str/split (str/trim (slurp f)) #"\s+"))
96-
(let [result (shell/sh
97-
"pgrep" "-P" (str pid))]
98-
(if (zero? (:exit result))
99-
(remove str/blank?
100-
(str/split-lines
101-
(:out result)))
102-
()))))
106+
(try
107+
(remove str/blank?
108+
(str/split (str/trim (slurp f)) #"\s+"))
109+
(catch Exception _
110+
(pgrep-child-pids pid)))
111+
(pgrep-child-pids pid)))
103112
(catch Exception _ nil)))
104113

105114
(defn- agent-pattern
@@ -121,7 +130,7 @@
121130
(zero? (:exit (shell/sh "kill" "-0" (str pid))))
122131
(catch Exception _ false)))))
123132

124-
(declare find-agent-in-tree)
133+
(declare find-agent-in-tree find-agent-pid-in-tree)
125134

126135
(defn- search-children
127136
"Searches child processes for an agent, returning the best
@@ -137,6 +146,13 @@
137146
:not-found
138147
children))
139148

149+
(defn- search-child-pids
150+
"Searches child processes for a matching agent pid."
151+
[children agent-type max-depth]
152+
(some #(find-agent-pid-in-tree
153+
% agent-type max-depth)
154+
children))
155+
140156
(defn find-agent-in-tree
141157
"Searches the process tree rooted at pid for an agent
142158
process matching the given agent-type.
@@ -164,6 +180,27 @@
164180
children agent-type
165181
(dec max-depth)))))))))
166182

183+
(defn find-agent-pid-in-tree
184+
"Searches the process tree rooted at pid for an agent
185+
process matching the given agent-type and returns the
186+
matching pid as a string. Returns nil when not found or
187+
process info is unavailable."
188+
([pid agent-type] (find-agent-pid-in-tree
189+
pid agent-type 5))
190+
([pid agent-type max-depth]
191+
(when-not (neg? max-depth)
192+
(let [pid-str (str pid)
193+
pat (agent-pattern agent-type)
194+
cmdline (read-proc-cmdline pid-str)]
195+
(cond
196+
(nil? cmdline) nil
197+
(re-find pat cmdline) pid-str
198+
:else
199+
(when-let [children (child-pids pid-str)]
200+
(search-child-pids
201+
children agent-type
202+
(dec max-depth))))))))
203+
167204
(defn- session-has-live-agent?
168205
"Checks if a session's agent is alive by searching the
169206
process tree of matching tmux panes.

‎src/ceeker/tui/app.clj‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[ceeker.tui.input :as input]
77
[ceeker.tui.view :as view]
88
[ceeker.tui.watcher :as watcher]
9+
[ceeker.watch.sessions :as session-watch]
910
[clojure.core.async :as async]
1011
[clojure.java.shell :as shell]
1112
[clojure.string :as str]))
@@ -155,27 +156,37 @@
155156

156157
(declare create-watcher-for)
157158

159+
(defn- start-runtime-workers [state-dir]
160+
(session-watch/scan-recent-sessions!
161+
{:state-dir state-dir})
162+
{:stop-ch (start-pane-checker! state-dir)
163+
:session-stop-ch
164+
(session-watch/start-session-watcher!
165+
{:state-dir state-dir})})
166+
158167
(defn- setup-runtime
159168
"Creates startup resources and optionally logs timings."
160169
[state-dir startup-profile?]
161170
(let [started-at (System/nanoTime)
162171
terminal-step (input/create-terminal-profile)
163172
watcher-step (timed-step
164173
#(create-watcher-for state-dir))
165-
checker-step (timed-step
166-
#(start-pane-checker! state-dir))]
174+
workers-step (timed-step
175+
#(start-runtime-workers state-dir))]
167176
(when startup-profile?
168177
(log-startup-profile!
169178
{:create-terminal-build (:build-ms terminal-step)
170179
:create-terminal-enter-raw-mode
171180
(:enter-raw-mode-ms terminal-step)
172181
:create-terminal-total (:total-ms terminal-step)
173182
:create-watcher (:elapsed-ms watcher-step)
174-
:start-pane-checker (:elapsed-ms checker-step)
183+
:start-pane-checker (:elapsed-ms workers-step)
175184
:total (elapsed-ms started-at)}))
176185
{:terminal (:terminal terminal-step)
177186
:watcher (:result watcher-step)
178-
:stop-ch (:result checker-step)}))
187+
:stop-ch (get-in workers-step [:result :stop-ch])
188+
:session-stop-ch
189+
(get-in workers-step [:result :session-stop-ch])}))
179190

180191
(defn- get-terminal-height
181192
"Gets the current terminal height from a JLine terminal."
@@ -376,7 +387,7 @@
376387
([] (start-tui! nil))
377388
([state-dir] (start-tui! state-dir {}))
378389
([state-dir opts]
379-
(let [{:keys [terminal watcher stop-ch]}
390+
(let [{:keys [terminal watcher stop-ch session-stop-ch]}
380391
(setup-runtime state-dir
381392
(:startup-profile opts))
382393
exit-on-jump? (:exit-on-jump opts)
@@ -386,7 +397,10 @@
386397
(tui-loop terminal watcher state-dir exit-on-jump?
387398
initial-display-mode)
388399
(finally
389-
(async/close! stop-ch)
400+
(when stop-ch
401+
(async/close! stop-ch))
402+
(when session-stop-ch
403+
(async/close! session-stop-ch))
390404
(print "\033[2J\033[H")
391405
(flush)
392406
(watcher/close-watcher watcher)

0 commit comments

Comments
 (0)