From 1e30d8262d74280da368a6fa57832a38a91dccce Mon Sep 17 00:00:00 2001 From: boxp Date: Thu, 2 Apr 2026 17:22:42 +0900 Subject: [PATCH] Add session list JSON option --- README.ja.md | 26 +++++ README.md | 26 +++++ .../llm-session-list-option/plan.md | 31 ++++++ src/ceeker/core.clj | 101 ++++++++++++------ src/ceeker/session_list.clj | 53 +++++++++ src/ceeker/tui/app.clj | 18 +--- test/ceeker/core_test.clj | 42 ++++++++ test/ceeker/session_list_test.clj | 64 +++++++++++ 8 files changed, 312 insertions(+), 49 deletions(-) create mode 100644 docs/project_docs/llm-session-list-option/plan.md create mode 100644 src/ceeker/session_list.clj create mode 100644 test/ceeker/session_list_test.clj diff --git a/README.ja.md b/README.ja.md index 35df3f4..6161355 100644 --- a/README.ja.md +++ b/README.ja.md @@ -109,6 +109,32 @@ ceeker | `c` | フィルタ全クリア | | `q` | 終了 | +### Session List JSON + +`--list-sessions` を付けると、ceeker は TUI を起動せず現在の session list を JSON で標準出力します。LLM や外部ツール連携向けのモードで、各 session には tmux pane を特定できる `pane_id` も含まれます。 + +```bash +ceeker --list-sessions +``` + +出力例: + +```json +[ + { + "session_id": "sess-123", + "agent_type": "codex", + "agent_status": "running", + "cwd": "/path/to/worktree", + "pane_id": "%42", + "last_message": "planning changes", + "last_updated": "2026-04-02T12:34:56Z" + } +] +``` + +出力前に ceeker は 1 回だけ同期的に pane 生存確認と capture ベースの状態更新を行います。tmux 更新に失敗した場合でも、保存済みの session list はそのまま返します。 + ### ジャンプ後に自動終了 `--exit-on-jump` を指定すると、ジャンプ成功後に ceeker が自動的に終了します。ポップアップで一時的に起動し、セッション選択→ジャンプ→自動クローズする運用に便利です。 diff --git a/README.md b/README.md index a6969b6..acca111 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,32 @@ Displays a list of all active sessions. | `c` | Clear all filters | | `q` | Quit | +### Session List JSON + +With `--list-sessions`, ceeker skips the TUI and prints the current session list as JSON. This is intended for LLM or tool integration. Each session includes `pane_id` so callers can identify the tmux pane directly. + +```bash +ceeker --list-sessions +``` + +Example output: + +```json +[ + { + "session_id": "sess-123", + "agent_type": "codex", + "agent_status": "running", + "cwd": "/path/to/worktree", + "pane_id": "%42", + "last_message": "planning changes", + "last_updated": "2026-04-02T12:34:56Z" + } +] +``` + +Before printing, ceeker performs one synchronous pane liveness and capture-based state refresh. If tmux refresh fails, ceeker still returns the stored session list. + ### Exit on Jump With `--exit-on-jump`, ceeker exits automatically after a successful jump. This is useful when running ceeker as a one-shot popup — select a session, jump, and the popup closes by itself. diff --git a/docs/project_docs/llm-session-list-option/plan.md b/docs/project_docs/llm-session-list-option/plan.md new file mode 100644 index 0000000..b4b0acf --- /dev/null +++ b/docs/project_docs/llm-session-list-option/plan.md @@ -0,0 +1,31 @@ +# llm-session-list-option Plan + +## Summary + +`ceeker` に TUI を起動せず現在の session list を JSON で返す +`--list-sessions` オプションを追加する。 +LLM が対象 pane を特定できるよう、既存の session 情報に加えて +`pane_id` を必ず出力する。 + +## Key Changes + +- CLI に `--list-sessions` を追加 +- `ceeker --list-sessions` は TUI を起動せず JSON を stdout に出力 +- 出力前に `tmux` pane の stale close と capture ベース状態更新を 1 回実行 +- 更新失敗時は fail-open で保存済み state を返す +- 出力 JSON のキーは snake_case に統一し、`agent_type` と + `agent_status` は文字列化する + +## Tests + +- `--list-sessions` の CLI parse +- list mode で TUI が起動しないこと +- JSON に `pane_id` が含まれること +- refresh 失敗時も JSON 出力が継続すること +- session 並び順が TUI と一致すること + +## Assumptions + +- 「今の session list」は TUI が参照している state 全件を指す +- 出力形式は今回は JSON 固定とする +- tmux 外セッションでも `pane_id` キーは出し、値は空文字にする diff --git a/src/ceeker/core.clj b/src/ceeker/core.clj index ae2f0b2..758b7dd 100644 --- a/src/ceeker/core.clj +++ b/src/ceeker/core.clj @@ -1,7 +1,9 @@ (ns ceeker.core "Entry point for ceeker CLI." (:require [ceeker.hook.handler :as hook] + [ceeker.session-list :as session-list] [ceeker.tui.app :as tui] + [cheshire.core :as json] [clojure.core.async :as async] [clojure.java.io :as io] [clojure.string :as str] @@ -20,6 +22,8 @@ :parse-fn keyword :validate [#{:auto :table :card} "Must be one of: auto, table, card"]] + [nil "--list-sessions" + "Print the current session list as JSON and exit"] [nil "--exit-on-jump" "Exit after a successful jump"] [nil "--startup-profile" "Log startup profiling to stderr"]]) @@ -33,6 +37,8 @@ "" "Usage:" " ceeker Start the TUI" + " ceeker --list-sessions" + " Print sessions as JSON" " ceeker hook []" " Handle a hook event" " ceeker hook " @@ -77,28 +83,29 @@ [args] (let [agent-type (first args) raw-second (second args)] - (when (or (nil? agent-type) (nil? raw-second)) - (binding [*out* *err*] - (println "Usage: ceeker hook ") - (println " agent: claude | codex")) - (System/exit 1)) - (let [{:keys [event-type payload]} - (resolve-hook-args args raw-second) - result (hook/handle-hook! - agent-type event-type payload)] - (binding [*out* *err*] - (println (str "ceeker: recorded " - (:agent-type result) " " - (or event-type "notify") - " for pane " - (:pane-id result))))))) + (if (or (nil? agent-type) (nil? raw-second)) + (do + (binding [*out* *err*] + (println "Usage: ceeker hook ") + (println " agent: claude | codex")) + 1) + (let [{:keys [event-type payload]} + (resolve-hook-args args raw-second) + result (hook/handle-hook! + agent-type event-type payload)] + (binding [*out* *err*] + (println (str "ceeker: recorded " + (:agent-type result) " " + (or event-type "notify") + " for pane " + (:pane-id result)))) + 0)))) (defn- print-errors! - "Prints CLI errors to stderr and exits." + "Prints CLI errors to stderr." [errors] (binding [*out* *err*] - (doseq [e errors] (println e))) - (System/exit 1)) + (doseq [e errors] (println e)))) (defn- tui-opts "Builds TUI opts from parsed CLI options." @@ -107,6 +114,42 @@ :startup-profile (:startup-profile options) :initial-display-mode (:view options)}) +(defn list-sessions-for-cli + "Returns the current session list for CLI output." + [state-dir] + (map session-list/session->external + (session-list/refresh-and-read-session-list state-dir))) + +(defn- print-session-list! + "Prints the current session list as JSON." + [state-dir] + (println (json/generate-string + (list-sessions-for-cli state-dir)))) + +(defn- run-parsed-cli! + "Executes the parsed CLI command and returns an exit code." + [{:keys [options arguments summary errors]}] + (cond + errors + (do (print-errors! errors) + 1) + (:version options) + (do (println (str "ceeker " version)) + 0) + (:help options) + (do (println (usage summary)) + 0) + (:list-sessions options) + (do (print-session-list! nil) + 0) + (= "hook" (first arguments)) + (handle-hook-command (rest arguments)) + :else + (do (tui/start-tui! + nil + (tui-opts options)) + 0))) + ;; musl? is evaluated at AOT compile time (macro expansion time). ;; For musl static builds, set CEEKER_STATIC=true CEEKER_MUSL=true ;; when running `clojure -T:build uber` to produce a musl-compatible binary. @@ -132,19 +175,9 @@ (run (let [{:keys [options arguments summary errors]} (cli/parse-opts args cli-options :in-order true)] - (cond - errors - (print-errors! errors) - (:version options) - (do (println (str "ceeker " version)) - (System/exit 0)) - (:help options) - (do (println (usage summary)) - (System/exit 0)) - (= "hook" (first arguments)) - (handle-hook-command (rest arguments)) - :else - (do (tui/start-tui! - nil - (tui-opts options)) - (System/exit 0)))))) + (System/exit + (run-parsed-cli! + {:options options + :arguments arguments + :summary summary + :errors errors}))))) diff --git a/src/ceeker/session_list.clj b/src/ceeker/session_list.clj new file mode 100644 index 0000000..5e1b7ee --- /dev/null +++ b/src/ceeker/session_list.clj @@ -0,0 +1,53 @@ +(ns ceeker.session-list + "Shared session list access for TUI and CLI." + (:require [ceeker.state.store :as store] + [ceeker.tmux.pane :as pane])) + +(defn sort-sessions + "Sorts sessions the same way as the TUI list." + [sessions] + (sort-by + (fn [session] + [(if (= :running (:agent-status session)) 0 1) + (or (:last-updated session) "")]) + sessions)) + +(defn read-session-list + "Reads all sessions from the state store and sorts them." + ([] (read-session-list nil)) + ([state-dir] + (let [state (if state-dir + (store/read-sessions state-dir) + (store/read-sessions))] + (sort-sessions (vals (:sessions state)))))) + +(defn refresh-session-state! + "Refreshes pane liveness and capture-based session states once." + [state-dir] + (pane/close-stale-sessions! state-dir) + (pane/refresh-session-states! state-dir)) + +(defn refresh-and-read-session-list + "Refreshes session state once, then reads the current session list. + Refresh failures are logged and do not prevent returning stored state." + ([] (refresh-and-read-session-list nil)) + ([state-dir] + (try + (refresh-session-state! state-dir) + (catch Exception e + (binding [*out* *err*] + (println + (str "ceeker: session list refresh failed: " + (.getMessage e)))))) + (sort-sessions (read-session-list state-dir)))) + +(defn session->external + "Converts an internal session map to a JSON-ready map." + [session] + {:session_id (:session-id session) + :agent_type (some-> (:agent-type session) name) + :agent_status (some-> (:agent-status session) name) + :cwd (:cwd session) + :pane_id (or (:pane-id session) "") + :last_message (:last-message session) + :last_updated (:last-updated session)}) diff --git a/src/ceeker/tui/app.clj b/src/ceeker/tui/app.clj index 95feb97..48f218f 100644 --- a/src/ceeker/tui/app.clj +++ b/src/ceeker/tui/app.clj @@ -1,6 +1,6 @@ (ns ceeker.tui.app "TUI application main loop." - (:require [ceeker.state.store :as store] + (:require [ceeker.session-list :as session-list] [ceeker.tmux.pane :as pane] [ceeker.tui.filter :as f] [ceeker.tui.input :as input] @@ -47,15 +47,6 @@ (recur))))) stop-ch))) -(defn- get-session-list - "Gets session list from state store." - ([] (get-session-list nil)) - ([state-dir] - (let [state (if state-dir - (store/read-sessions state-dir) - (store/read-sessions))] - (vals (:sessions state))))) - (defn- find-tmux-pane "Finds a tmux pane matching the given cwd." [cwd] @@ -116,10 +107,7 @@ (defn- filtered-sorted "Applies filters then sorts sessions." [sessions filter-state] - (sort-by - (fn [s] - [(if (= :running (:agent-status s)) 0 1) - (or (:last-updated s) "")]) + (session-list/sort-sessions (f/apply-filters filter-state sessions))) (defn- get-terminal-width @@ -338,7 +326,7 @@ (defn- render-and-read "Renders current state and reads one key event." [terminal w state-dir sel msg fs sm? sb display-mode] - (let [sessions (get-session-list state-dir) + (let [sessions (session-list/read-session-list state-dir) visible (filtered-sorted sessions fs) mx (max 0 (dec (count visible))) cl (clamp sel 0 mx) diff --git a/test/ceeker/core_test.clj b/test/ceeker/core_test.clj index 0b0ada7..0f190be 100644 --- a/test/ceeker/core_test.clj +++ b/test/ceeker/core_test.clj @@ -1,6 +1,8 @@ (ns ceeker.core-test (:require [ceeker.core :as core] + [ceeker.tui.app] [ceeker.tui.view :as view] + [cheshire.core :as json] [clojure.string :as str] [clojure.test :refer [deftest is testing]] [clojure.tools.cli :as cli])) @@ -98,6 +100,15 @@ (is (nil? errors)) (is (true? (:startup-profile options)))))) +(deftest cli-options-include-list-sessions + (testing "--list-sessions is parsed as a boolean option" + (let [{:keys [options errors]} + (cli/parse-opts ["--list-sessions"] + core/cli-options + :in-order true)] + (is (nil? errors)) + (is (true? (:list-sessions options)))))) + (deftest cli-accepts-view-option (testing "--view accepts supported startup views" (doseq [mode ["auto" "table" "card"]] @@ -128,3 +139,34 @@ :in-order true)] (is (nil? errors)) (is (= :auto (:view options)))))) + +(deftest run-parsed-cli-list-sessions-prints-json-and-skips-tui + (testing "list mode prints session JSON and does not start TUI" + (let [out (java.io.StringWriter.)] + (binding [*out* out] + (with-redefs [ceeker.core/list-sessions-for-cli + (fn [_] + [{:session_id "sess-1" + :agent_type "codex" + :agent_status "running" + :cwd "/tmp/work" + :pane_id "%42" + :last_message nil + :last_updated "2026-04-02T00:00:00Z"}]) + ceeker.tui.app/start-tui! + (fn [& _] + (throw (ex-info "TUI should not start" + {})))] + (is (= 0 (#'core/run-parsed-cli! + {:options {:list-sessions true} + :arguments [] + :summary "" + :errors nil}))))) + (let [payload (json/parse-string (str out) true) + first-session (first payload)] + (is (= 1 (count payload))) + (is (= "sess-1" (:session_id first-session))) + (is (= "codex" (:agent_type first-session))) + (is (= "running" (:agent_status first-session))) + (is (= "%42" (:pane_id first-session))) + (is (contains? first-session :last_message)))))) diff --git a/test/ceeker/session_list_test.clj b/test/ceeker/session_list_test.clj new file mode 100644 index 0000000..fc477b2 --- /dev/null +++ b/test/ceeker/session_list_test.clj @@ -0,0 +1,64 @@ +(ns ceeker.session-list-test + (:require [ceeker.session-list :as session-list] + [clojure.test :refer [deftest is testing]])) + +(deftest refresh-and-read-session-list-sorts-like-tui + (testing "running sessions come first, then by last-updated" + (with-redefs [ceeker.session-list/refresh-session-state! + (fn [_]) + ceeker.session-list/read-session-list + (fn [_] + [{:session-id "done" + :agent-status :completed + :pane-id "%3" + :last-updated "2026-04-02T03:00:00Z"} + {:session-id "run-new" + :agent-status :running + :pane-id "%2" + :last-updated "2026-04-02T02:00:00Z"} + {:session-id "run-old" + :agent-status :running + :pane-id "%1" + :last-updated "2026-04-02T01:00:00Z"}])] + (is (= ["run-old" "run-new" "done"] + (map :session-id + (session-list/refresh-and-read-session-list + nil))))))) + +(deftest refresh-and-read-session-list-fails-open + (testing "refresh failure still returns stored sessions" + (let [err (java.io.StringWriter.)] + (binding [*err* err] + (with-redefs [ceeker.session-list/refresh-session-state! + (fn [_] + (throw (ex-info "tmux unavailable" {}))) + ceeker.session-list/read-session-list + (fn [_] + [{:session-id "sess-1" + :agent-status :running + :pane-id "%1" + :last-updated "2026-04-02T01:00:00Z"}])] + (is (= ["sess-1"] + (map :session-id + (session-list/refresh-and-read-session-list + nil)))))) + (is (re-find #"session list refresh failed" + (str err)))))) + +(deftest session->external-includes-pane-id-and-stringifies-enums + (testing "JSON-ready map uses snake_case keys for LLM consumers" + (is (= {:session_id "sess-1" + :agent_type "claude-code" + :agent_status "waiting" + :cwd "/tmp/work" + :pane_id "" + :last_message nil + :last_updated "2026-04-02T00:00:00Z"} + (session-list/session->external + {:session-id "sess-1" + :agent-type :claude-code + :agent-status :waiting + :cwd "/tmp/work" + :pane-id nil + :last-message nil + :last-updated "2026-04-02T00:00:00Z"})))))