ring-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ring/registry.py ADDED
@@ -0,0 +1,851 @@
1
+ """RiNG 資料層:把「目前有哪些 Claude Code session 在台上」抓出來。
2
+
3
+ 兩種來源,優先順序由高到低:
4
+
5
+ 1. hook registry(精準模式):``~/.config/ring/sessions/*.json``,由 RiNG 的 hook
6
+ 腳本在 SessionStart / Notification / UserPromptSubmit / Stop / SessionEnd
7
+ 等事件即時寫入。能精準知道「這個 session 需要你決策」。
8
+ 2. zero-config fallback:直接掃 ``~/.claude/projects/**/*.jsonl``,用檔案 mtime
9
+ 推活躍度,從記錄裡的 ``cwd`` 欄位還原真實路徑(避開目錄名以 ``-`` 編碼
10
+ 造成的 hyphen 還原歧義)。scan 模式不把「回完一輪」當成 🔴 WAITING;
11
+ WAITING 只保留給 hook 可確認的權限 / 選項等互動。
12
+
13
+ 額外富化:
14
+ - ``tmux_target``:靠 tmux pane 的 current_path 對 cwd,給你「去哪」的座標。
15
+ - ``todo``:解析 transcript 裡最新的 TodoWrite,給 done/total 真進度。
16
+
17
+ 純 stdlib,不依賴任何第三方套件。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sqlite3
25
+ import subprocess
26
+ import time
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from enum import StrEnum
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from ring.config import get_config
34
+
35
+ CLAUDE_PROJECTS = Path.home() / ".claude" / "projects"
36
+ RING_REGISTRY = Path.home() / ".config" / "ring" / "sessions"
37
+ CODEX_STATE = Path.home() / ".codex" / "state_5.sqlite"
38
+
39
+ _CFG = get_config()
40
+ ACTIVE_WINDOW_SECONDS = _CFG.active_window_seconds # 只看最近這段時間動過的 session(預設 6h)
41
+ WORKING_THRESHOLD_SECONDS = _CFG.working_threshold_seconds # 多久沒動 → 🟢 工作中 變 🟡 閒置
42
+ WAITING_WINDOW_SECONDS = _CFG.waiting_window_seconds # IDLE 升 WAITING 的時間窗上限(預設 30 分)
43
+ _SUBPROCESS_CACHE_TTL = 1.0 # ps / tmux 結果的短快取,省掉同一次刷新內的重複呼叫
44
+
45
+ # Claude Code SessionStart payload 的 source 值(不是 provider)。舊版 bug 曾把它誤當
46
+ # provider 寫進 registry,留下接不住的幽靈列;載入時據此辨識並清掉這種腐壞檔。
47
+ _SESSION_START_SOURCES = {"startup", "resume", "clear", "compact"}
48
+
49
+ # Provider → 「當下 live process 的 (cwd, tty) 清單」偵測器。core 不認識任何具體工具:
50
+ # 要支援新工具的存活偵測=註冊一個偵測器,_hook_sessions / sources 零改動。
51
+ # 同義 provider 名先正規化(例如 "claude" → "claude-code")。
52
+ _PROVIDER_ALIASES: dict[str, str] = {"claude": "claude-code"}
53
+ _PROVIDER_PROCS: dict[str, Callable[[], list[tuple[str, str]]]] = {}
54
+
55
+
56
+ def _canonical_provider(provider: str) -> str:
57
+ """把同義 provider 名收斂成偵測器註冊用的標準鍵。"""
58
+ return _PROVIDER_ALIASES.get(provider, provider)
59
+
60
+
61
+ def register_provider_procs(provider: str, detector: Callable[[], list[tuple[str, str]]]) -> None:
62
+ """註冊某 provider 的 live-process 偵測器(回傳 ``[(cwd, tty), …]``)。
63
+
64
+ 有偵測器的 provider 才會在 ``_hook_sessions`` 走 process-based 存活清理;沒註冊的
65
+ provider 一律 fail-open(不靠 process 判離場,交給該工具自己的 SessionEnd hook)。
66
+ """
67
+ _PROVIDER_PROCS[_canonical_provider(provider)] = detector
68
+
69
+
70
+ def collect_provider_procs() -> dict[str, list[tuple[str, str]]]:
71
+ """所有已註冊 provider 的當下 live procs,鍵為標準 provider 名。"""
72
+ return {provider: detector() for provider, detector in _PROVIDER_PROCS.items()}
73
+
74
+
75
+ class Status(StrEnum):
76
+ """場館裡一個 session 的狀態。等你的排最上面。"""
77
+
78
+ WAITING = "waiting" # 🔴 在等你進場(hook 模式才測得準)
79
+ WORKING = "working" # 🟢 台上正在跑
80
+ IDLE = "idle" # 🟡 一回合跑完、停著
81
+ ENDED = "ended" # ⚫ 已離場
82
+
83
+ @property
84
+ def rank(self) -> int:
85
+ return {Status.WAITING: 0, Status.WORKING: 1, Status.IDLE: 2, Status.ENDED: 3}[self]
86
+
87
+ @property
88
+ def marker(self) -> str:
89
+ return {Status.WAITING: "🔴", Status.WORKING: "🟢", Status.IDLE: "🟡", Status.ENDED: "⚫"}[self]
90
+
91
+
92
+ @dataclass
93
+ class Session:
94
+ session_id: str
95
+ cwd: str
96
+ status: Status
97
+ last_active: float
98
+ last_action: str
99
+ source: str # "hook" | "scan" | "proc"
100
+ tmux_target: str | None = None # e.g. "main:1.0"
101
+ tty: str | None = None # e.g. "/dev/ttys003",給非-tmux 終端(iTerm2 等)聚焦用
102
+ todo: tuple[int, int] | None = None # (done, total)
103
+ recent_actions: list[str] = field(default_factory=list)
104
+ provider: str = ""
105
+ _tail_kind: str = field(default="none", repr=False, compare=False) # 內部:scan 路徑暫存對話尾判定
106
+ origin_cwd: str = "" # 開場 cwd(session 第一筆帶 cwd 紀錄),用於歸屬;空時 fallback 到 cwd
107
+
108
+ @property
109
+ def project(self) -> str:
110
+ """session 所屬專案名稱。
111
+
112
+ 優先用 ``origin_cwd``(開場 cwd)——確保中途 ``cd`` 過的 session 仍歸屬到
113
+ 它真正的專案,而非漂到目的地專案。``origin_cwd`` 未設時 fallback 到 ``cwd``,
114
+ 行為與舊版一致(hook / proc 等來源的 cwd 本就穩定)。
115
+ """
116
+ base = self.origin_cwd or self.cwd
117
+ return Path(base).name or base
118
+
119
+ @property
120
+ def idle_for(self) -> float:
121
+ return max(0.0, time.time() - self.last_active)
122
+
123
+ @property
124
+ def location(self) -> str:
125
+ """「去哪」:有 tmux 座標就給座標,否則給縮寫 cwd。"""
126
+ if self.tmux_target:
127
+ return self.tmux_target
128
+ home = str(Path.home())
129
+ return self.cwd.replace(home, "~", 1) if self.cwd.startswith(home) else self.cwd
130
+
131
+
132
+ def _tail_records(path: Path, max_tail: int = 128 * 1024) -> list[dict[str, Any]]:
133
+ """讀 JSONL 檔尾若干筆合法 JSON 記錄(舊→新),只 seek 檔尾不整檔讀。"""
134
+ try:
135
+ size = path.stat().st_size
136
+ with path.open("rb") as f:
137
+ if size > max_tail:
138
+ f.seek(size - max_tail)
139
+ f.readline() # 丟掉被切一半的那行
140
+ chunk = f.read()
141
+ except OSError:
142
+ return []
143
+ out = []
144
+ for raw in chunk.splitlines():
145
+ raw = raw.strip()
146
+ if not raw:
147
+ continue
148
+ try:
149
+ out.append(json.loads(raw))
150
+ except json.JSONDecodeError:
151
+ continue
152
+ return out
153
+
154
+
155
+ def _head_cwd(path: Path, max_head: int = 128 * 1024) -> str:
156
+ """讀 JSONL 檔頭,回傳第一筆含非空 ``cwd`` 欄位的紀錄之 cwd。
157
+
158
+ 目的:修補 scan 模式「中途 cd 漂移」問題。Claude Code session 中途 ``cd``
159
+ 後,新紀錄的 ``cwd`` 已指向目的地目錄,若只看檔尾(``_tail_records``)會
160
+ 誤判 session 歸屬。改讀檔頭取「開場 cwd」才能把 session 留在它真正所屬的
161
+ 專案列,而非漂到目的地專案。
162
+
163
+ 跳過 JSONL 裡無 ``cwd`` 的 meta 筆(last-prompt、mode、permission-mode、
164
+ file-history-snapshot 等);命中即停,不必整檔讀完。
165
+
166
+ :param path: JSONL 檔案路徑。
167
+ :param max_head: 從檔頭最多讀取的位元組數,預設 128 KiB,足以涵蓋早期幾十筆紀錄。
168
+ :returns: 第一筆非空 cwd 字串;找不到(空檔、全無 cwd)回 ``""``。
169
+ """
170
+ try:
171
+ with path.open("rb") as f:
172
+ chunk = f.read(max_head)
173
+ except OSError:
174
+ return ""
175
+ for raw in chunk.splitlines():
176
+ raw = raw.strip()
177
+ if not raw:
178
+ continue
179
+ try:
180
+ record = json.loads(raw)
181
+ except json.JSONDecodeError:
182
+ continue
183
+ cwd = record.get("cwd")
184
+ if cwd and isinstance(cwd, str):
185
+ return cwd
186
+ return ""
187
+
188
+
189
+ def _blocks(record: dict[str, Any]) -> list[Any]:
190
+ msg = record.get("message")
191
+ if isinstance(msg, dict):
192
+ content = msg.get("content")
193
+ if isinstance(content, list):
194
+ return content
195
+ if isinstance(content, str):
196
+ return [{"type": "text", "text": content}]
197
+ return []
198
+
199
+
200
+ _CMD_NOISE = ("<local-command-stdout>", "<command-name>", "<command-message>", "<command-args>")
201
+
202
+
203
+ def _clean_text(s: str) -> str:
204
+ s = s.strip()
205
+ if any(marker in s for marker in _CMD_NOISE):
206
+ return "" # slash-command 的 stdout / 標記,不是真動作
207
+ return s.splitlines()[0].strip() if s else "" # 只取第一行,旁白長文不洗版
208
+
209
+
210
+ def _tool_summary(block: dict[str, Any]) -> str:
211
+ """把一個 tool_use 變成簡短摘要,例如 → Edit foo.py / → Bash: git status。"""
212
+ name = str(block.get("name") or "")
213
+ if not name:
214
+ return ""
215
+ inp = block.get("input")
216
+ if not isinstance(inp, dict):
217
+ inp = {}
218
+ if name in ("Edit", "Write", "Read", "NotebookEdit"):
219
+ path = inp.get("file_path") or inp.get("notebook_path")
220
+ if path:
221
+ return f"→ {name} {Path(str(path)).name}"
222
+ elif name == "Bash":
223
+ cmd = str(inp.get("command") or "").strip().replace("\n", " ")
224
+ if cmd:
225
+ return f"→ Bash: {cmd[:40]}"
226
+ elif name in ("Grep", "Glob") and inp.get("pattern"):
227
+ return f"→ {name} {inp['pattern']}"
228
+ return f"→ {name}"
229
+
230
+
231
+ def _latest_action(records: list[dict[str, Any]]) -> str:
232
+ """從新到舊找最近一個「真動作」:agent 的 tool_use 優先,其次 agent 的文字。
233
+
234
+ 跳過 user 訊息、tool_result、slash-command 輸出這類雜訊。
235
+ """
236
+ for record in reversed(records):
237
+ for block in _blocks(record):
238
+ if isinstance(block, dict) and block.get("type") == "tool_use":
239
+ summary = _tool_summary(block)
240
+ if summary:
241
+ return summary
242
+ if record.get("type") == "assistant":
243
+ for block in _blocks(record):
244
+ if isinstance(block, dict) and block.get("type") == "text":
245
+ txt = _clean_text(str(block.get("text", "")))
246
+ if txt:
247
+ return txt[:80]
248
+ return "—"
249
+
250
+
251
+ def _extract_todo(records: list[dict[str, Any]]) -> tuple[int, int] | None:
252
+ """從新到舊找最新的 TodoWrite,回傳 (done, total)。真進度訊號。"""
253
+ for record in reversed(records):
254
+ for block in _blocks(record):
255
+ if not isinstance(block, dict):
256
+ continue
257
+ if block.get("type") == "tool_use" and block.get("name") == "TodoWrite":
258
+ todos = (block.get("input") or {}).get("todos")
259
+ if isinstance(todos, list) and todos:
260
+ done = sum(1 for t in todos if isinstance(t, dict) and t.get("status") == "completed")
261
+ return done, len(todos)
262
+ return None
263
+
264
+
265
+ def _conversation_tail_kind(records: list[dict[str, Any]]) -> str:
266
+ """從對話尾巴往回走,判定最近一個「真訊息紀錄」的性質。
267
+
268
+ 回傳值(str):
269
+ - ``"waiting"`` : 對話最後是 assistant end_turn 收尾,Claude 回完一輪。
270
+ - ``"working"`` : 對話最後是真人送出的 prompt,輪到 Claude 回應。
271
+ - ``"interrupted"`` : 工具呼叫進行中(assistant tool_use / user tool_result),尚未完成一輪。
272
+ - ``"none"`` : 沒有可判定的訊息(空 records、全噪音、無 assistant/user 訊息)。
273
+
274
+ 演算法:從 ``reversed(records)`` 往回走,跳過噪音,找第一個真訊息紀錄,
275
+ 依 type 與 content 判定後立即 return。絕不直接看 records[-1]——尾巴幾乎都是噪音。
276
+
277
+ 限制(誠實標明):靠對話尾巴猜。Notification(卡權限等授權流程)在 JSONL 不留痕跡,
278
+ scan 模式測不到——那種需要使用者決策的狀態仍需 hook 模式偵測。
279
+ """
280
+ for record in reversed(records):
281
+ t = record.get("type")
282
+ # --- 跳過噪音 ---
283
+ if t not in ("user", "assistant"):
284
+ continue # file-history-snapshot / system / permission-mode / mode / last-prompt 等
285
+ if record.get("isMeta") is True:
286
+ continue
287
+ if record.get("isSidechain") is True:
288
+ continue
289
+ # --- 第一個真訊息紀錄,依 type 判定 ---
290
+ if t == "assistant":
291
+ msg = record.get("message")
292
+ stop_reason = msg.get("stop_reason") if isinstance(msg, dict) else None
293
+ # assistant 帶 tool_use block → 工具呼叫進行中
294
+ if any(isinstance(b, dict) and b.get("type") == "tool_use" for b in _blocks(record)):
295
+ return "interrupted"
296
+ if stop_reason == "end_turn":
297
+ return "waiting"
298
+ # tool_use / stop_sequence / None / 其他 → 視為中途被打斷
299
+ return "interrupted"
300
+ # t == "user"
301
+ # user 帶工具結果(toolUseResult 欄位,或 content 含 tool_result block)→ 中途
302
+ if record.get("toolUseResult") is not None:
303
+ return "interrupted"
304
+ if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in _blocks(record)):
305
+ return "interrupted"
306
+ # command 噪音(slash-command stdout)→ 跳過,繼續往回走
307
+ text_blocks = [b for b in _blocks(record) if isinstance(b, dict) and b.get("type") == "text"]
308
+ if text_blocks and all(_clean_text(str(b.get("text", ""))) == "" for b in text_blocks):
309
+ continue
310
+ # 真人 prompt → 輪到 Claude 回應,不是回合結束
311
+ return "working"
312
+ return "none"
313
+
314
+
315
+ def _apply_waiting(
316
+ status: Status,
317
+ idle_seconds: float,
318
+ tail_kind: str,
319
+ waiting_window: float,
320
+ ) -> Status:
321
+ """對話尾是 end_turn 且在時間窗內時,將 live/idle scan row 收斂為 IDLE。
322
+
323
+ 純函式、可單測,不依賴 module-level 常數。
324
+ 不把回合結束升成 WAITING;WAITING 只保留給 hook 確認的權限 / 選項互動:
325
+ - WORKING(< 90s):若尾端已是 end_turn,代表回合其實結束了,收斂成 IDLE。
326
+ - ENDED:超過活躍窗,不升。
327
+ """
328
+ if status in {Status.WORKING, Status.IDLE} and tail_kind == "waiting" and idle_seconds < waiting_window:
329
+ return Status.IDLE
330
+ return status
331
+
332
+
333
+ def _recent_actions(records: list[dict[str, Any]], n: int = 5) -> list[str]:
334
+ acts = []
335
+ for record in records:
336
+ for block in _blocks(record):
337
+ if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("name"):
338
+ acts.append(str(block["name"]))
339
+ return acts[-n:]
340
+
341
+
342
+ _tmux_cache: tuple[float, dict[str, str]] = (-1.0, {})
343
+
344
+
345
+ def _tmux_targets() -> dict[str, str]:
346
+ """tmux pane current_path → "session:window.pane" 對照表。沒 tmux 就空。短快取。"""
347
+ global _tmux_cache
348
+ now = time.monotonic()
349
+ if 0.0 <= now - _tmux_cache[0] <= _SUBPROCESS_CACHE_TTL:
350
+ return _tmux_cache[1]
351
+ mapping: dict[str, str] = {}
352
+ try:
353
+ out = subprocess.run(
354
+ ["tmux", "list-panes", "-a", "-F", "#{pane_current_path}\t#{session_name}:#{window_index}.#{pane_index}"],
355
+ capture_output=True,
356
+ text=True,
357
+ timeout=3,
358
+ )
359
+ if out.returncode == 0:
360
+ for line in out.stdout.splitlines():
361
+ if "\t" in line:
362
+ path, target = line.split("\t", 1)
363
+ mapping.setdefault(path, target)
364
+ except (OSError, subprocess.SubprocessError):
365
+ mapping = {}
366
+ _tmux_cache = (now, mapping)
367
+ return mapping
368
+
369
+
370
+ _pids_cache: tuple[float, list[int]] = (-1.0, [])
371
+ _codex_pids_cache: tuple[float, list[int]] = (-1.0, [])
372
+
373
+
374
+ def running_claude_pids() -> list[int]:
375
+ global _pids_cache
376
+ now = time.monotonic()
377
+ if 0.0 <= now - _pids_cache[0] <= _SUBPROCESS_CACHE_TTL:
378
+ return _pids_cache[1]
379
+ try:
380
+ out = subprocess.run(["ps", "-Ao", "pid,comm,args"], capture_output=True, text=True, timeout=3).stdout
381
+ except (OSError, subprocess.SubprocessError):
382
+ out = ""
383
+ pids: list[int] = []
384
+ for line in out.splitlines()[1:]:
385
+ parts = line.split(None, 2)
386
+ if len(parts) >= 2 and os.path.basename(parts[1].strip()) == "claude":
387
+ args = parts[2] if len(parts) == 3 else ""
388
+ if _is_claude_background_process(args):
389
+ continue
390
+ try:
391
+ pids.append(int(parts[0]))
392
+ except ValueError:
393
+ pass
394
+ _pids_cache = (now, pids)
395
+ return pids
396
+
397
+
398
+ def _is_claude_background_process(args: str) -> bool:
399
+ """Claude daemon / bg pty host 不是使用者可聚焦的 CLI session。"""
400
+ tokens = args.split()
401
+ if len(tokens) >= 3 and tokens[1:3] == ["daemon", "run"]:
402
+ return True
403
+ return "--bg-pty-host" in tokens
404
+
405
+
406
+ def running_codex_pids() -> list[int]:
407
+ global _codex_pids_cache
408
+ now = time.monotonic()
409
+ if 0.0 <= now - _codex_pids_cache[0] <= _SUBPROCESS_CACHE_TTL:
410
+ return _codex_pids_cache[1]
411
+ try:
412
+ out = subprocess.run(["ps", "-Ao", "pid,comm"], capture_output=True, text=True, timeout=3).stdout
413
+ except (OSError, subprocess.SubprocessError):
414
+ out = ""
415
+ pids: list[int] = []
416
+ for line in out.splitlines()[1:]:
417
+ parts = line.split(None, 1)
418
+ if len(parts) == 2 and os.path.basename(parts[1].strip()) == "codex":
419
+ try:
420
+ pids.append(int(parts[0]))
421
+ except ValueError:
422
+ pass
423
+ _codex_pids_cache = (now, pids)
424
+ return pids
425
+
426
+
427
+ def running_agent_pids() -> list[int]:
428
+ """所有內建來源看得到的 live agent CLI 行程。"""
429
+ return [*running_claude_pids(), *running_codex_pids()]
430
+
431
+
432
+ def _pid_cwd(pid: int) -> str:
433
+ try:
434
+ out = subprocess.run(
435
+ ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
436
+ capture_output=True,
437
+ text=True,
438
+ timeout=3,
439
+ ).stdout
440
+ except (OSError, subprocess.SubprocessError):
441
+ return ""
442
+ for line in out.splitlines():
443
+ if line.startswith("n"):
444
+ return line[1:]
445
+ return ""
446
+
447
+
448
+ def _real(path: str) -> str:
449
+ """正規化路徑供「session cwd ↔ live process cwd」比對。
450
+
451
+ lsof 回報的是解析過 symlink 的真實路徑,但 hook / JSONL / sqlite 記的常是字面
452
+ 路徑;兩者直接字串比對,遇到 symlink 專案路徑會對不上,導致活著的 session 被誤判
453
+ 離場(counts 為 0)或被補成重複列。各自 realpath 後再比對即可避免。只用於比對鍵,
454
+ 不改動 ``Session.cwd`` 的顯示值。
455
+ """
456
+ if not path:
457
+ return path
458
+ try:
459
+ return os.path.realpath(path)
460
+ except OSError:
461
+ return path
462
+
463
+
464
+ def _pid_tty(pid: int) -> str:
465
+ """claude process 的控制終端,正規化成 iTerm2 認得的 "/dev/ttysNNN"。"""
466
+ try:
467
+ tty = subprocess.run(
468
+ ["ps", "-o", "tty=", "-p", str(pid)], capture_output=True, text=True, timeout=3
469
+ ).stdout.strip()
470
+ except (OSError, subprocess.SubprocessError):
471
+ return ""
472
+ if not tty or tty in ("??", "?"):
473
+ return ""
474
+ return tty if tty.startswith("/dev/") else f"/dev/{tty}"
475
+
476
+
477
+ def _claude_procs() -> list[tuple[str, str]]:
478
+ """每個還活著的 claude:(cwd, tty)。cwd 判活躍/分流,tty 給終端跳轉。
479
+
480
+ claude 不會把 session 的 jsonl 一直開著(lsof 看不到),但給得到 cwd 與 tty。
481
+ 同一個 cwd 可能同時開好幾個 session,所以之後在 cwd 群組裡只有 mtime 最新的
482
+ 這幾個算活著,其餘同專案的舊 session=已離場。
483
+ """
484
+ procs: list[tuple[str, str]] = []
485
+ for pid in running_claude_pids():
486
+ cwd = _pid_cwd(pid)
487
+ if cwd:
488
+ procs.append((cwd, _pid_tty(pid)))
489
+ return procs
490
+
491
+
492
+ def _codex_procs() -> list[tuple[str, str]]:
493
+ """每個還活著的 Codex CLI:(cwd, tty)。"""
494
+ procs: list[tuple[str, str]] = []
495
+ for pid in running_codex_pids():
496
+ cwd = _pid_cwd(pid)
497
+ if cwd:
498
+ procs.append((cwd, _pid_tty(pid)))
499
+ return procs
500
+
501
+
502
+ # 內建 provider 的 live-process 偵測器。外部工具用 register_provider_procs() 加自己的。
503
+ register_provider_procs("claude-code", _claude_procs)
504
+ register_provider_procs("codex", _codex_procs)
505
+
506
+
507
+ def _codex_tail_kind(records: list[dict[str, Any]]) -> str:
508
+ """判定 Codex rollout 尾端狀態。
509
+
510
+ 回傳值:
511
+ - ``"waiting"``:Codex 已完成一輪、回到等使用者輸入。
512
+ - ``"working"``:最後仍在處理使用者輸入或工具呼叫。
513
+ - ``"none"``:沒有可判斷事件。
514
+ """
515
+ for record in reversed(records):
516
+ record_type = record.get("type")
517
+ payload = record.get("payload")
518
+ if not isinstance(payload, dict):
519
+ payload = {}
520
+ if record_type == "event_msg":
521
+ event_type = payload.get("type")
522
+ if event_type == "task_complete":
523
+ return "waiting"
524
+ if event_type in {"task_started", "user_message", "agent_message"}:
525
+ return "working"
526
+ if record_type == "response_item":
527
+ item_type = payload.get("type")
528
+ if item_type == "message":
529
+ if payload.get("role") == "assistant" and payload.get("phase") == "final_answer":
530
+ return "waiting"
531
+ return "working"
532
+ if item_type in {"function_call", "function_call_output"}:
533
+ return "working"
534
+ return "none"
535
+
536
+
537
+ def _codex_latest_action(records: list[dict[str, Any]], fallback: str) -> str:
538
+ """從 Codex rollout 尾端取簡短動作摘要。"""
539
+ for record in reversed(records):
540
+ payload = record.get("payload")
541
+ if not isinstance(payload, dict):
542
+ continue
543
+ if record.get("type") == "response_item" and payload.get("type") == "function_call":
544
+ name = str(payload.get("name") or "").strip()
545
+ if name:
546
+ return f"→ {name}"
547
+ if record.get("type") == "event_msg" and payload.get("type") == "agent_message":
548
+ msg = str(payload.get("message") or "").strip()
549
+ if msg:
550
+ return msg.splitlines()[0][:80]
551
+ return fallback or "—"
552
+
553
+
554
+ def _codex_threads(procs: list[tuple[str, str]]) -> list[Session]:
555
+ """從 Codex state sqlite 讀 thread,並用 live codex process 粗略判斷活性。"""
556
+ if not CODEX_STATE.exists():
557
+ return []
558
+ try:
559
+ con = sqlite3.connect(f"file:{CODEX_STATE}?mode=ro", uri=True)
560
+ con.row_factory = sqlite3.Row
561
+ rows = con.execute(
562
+ """
563
+ select id, cwd, title, rollout_path, preview, updated_at, updated_at_ms
564
+ from threads
565
+ where archived = 0
566
+ order by coalesce(nullif(updated_at_ms, 0), updated_at * 1000) desc
567
+ limit 200
568
+ """
569
+ ).fetchall()
570
+ except sqlite3.Error:
571
+ return []
572
+ finally:
573
+ try:
574
+ con.close()
575
+ except UnboundLocalError:
576
+ pass
577
+
578
+ now = time.time()
579
+ counts: dict[str, int] = {}
580
+ cwd_ttys: dict[str, list[str]] = {}
581
+ for cwd, tty in procs:
582
+ key = _real(cwd)
583
+ counts[key] = counts.get(key, 0) + 1
584
+ cwd_ttys.setdefault(key, []).append(tty)
585
+
586
+ raw: list[Session] = []
587
+ for row in rows:
588
+ cwd = str(row["cwd"] or "")
589
+ if not cwd:
590
+ continue
591
+ updated_ms = int(row["updated_at_ms"] or 0)
592
+ last_active = updated_ms / 1000 if updated_ms else float(row["updated_at"] or 0)
593
+ if now - last_active > ACTIVE_WINDOW_SECONDS and counts.get(cwd, 0) == 0:
594
+ continue
595
+ rollout_path = Path(str(row["rollout_path"] or ""))
596
+ records = _tail_records(rollout_path) if rollout_path else []
597
+ title = str(row["title"] or row["preview"] or "")
598
+ tail_kind = _codex_tail_kind(records)
599
+ raw.append(
600
+ Session(
601
+ session_id=f"codex:{row['id']}",
602
+ cwd=cwd,
603
+ status=Status.ENDED,
604
+ last_active=last_active,
605
+ last_action=_codex_latest_action(records, title),
606
+ source="codex",
607
+ provider="codex",
608
+ _tail_kind=tail_kind,
609
+ origin_cwd=cwd,
610
+ )
611
+ )
612
+
613
+ by_cwd: dict[str, list[Session]] = {}
614
+ for s in raw:
615
+ by_cwd.setdefault(s.cwd, []).append(s)
616
+
617
+ out: list[Session] = []
618
+ for cwd, group in by_cwd.items():
619
+ group.sort(key=lambda s: s.last_active, reverse=True)
620
+ ckey = _real(cwd)
621
+ live_n = counts.get(ckey, 0)
622
+ uniq_tty = cwd_ttys[ckey][0] if live_n == 1 and cwd_ttys.get(ckey) else ""
623
+ for i, s in enumerate(group):
624
+ if i < live_n:
625
+ idle = now - s.last_active
626
+ s.status = Status.IDLE if s._tail_kind == "waiting" else _scan_status(idle)
627
+ if i == 0 and uniq_tty:
628
+ s.tty = uniq_tty
629
+ out.append(s)
630
+ return out
631
+
632
+
633
+ def _scan_status(idle_seconds: float) -> Status:
634
+ if idle_seconds < WORKING_THRESHOLD_SECONDS:
635
+ return Status.WORKING
636
+ if idle_seconds < ACTIVE_WINDOW_SECONDS:
637
+ return Status.IDLE
638
+ return Status.ENDED
639
+
640
+
641
+ def _scan_sessions(procs: list[tuple[str, str]]) -> list[Session]:
642
+ if not CLAUDE_PROJECTS.is_dir():
643
+ return []
644
+ now = time.time()
645
+ counts: dict[str, int] = {}
646
+ cwd_ttys: dict[str, list[str]] = {}
647
+ for cwd, tty in procs:
648
+ key = _real(cwd)
649
+ counts[key] = counts.get(key, 0) + 1
650
+ cwd_ttys.setdefault(key, []).append(tty)
651
+
652
+ raw: list[Session] = []
653
+ for project_dir in CLAUDE_PROJECTS.iterdir():
654
+ if not project_dir.is_dir():
655
+ continue
656
+ for jsonl in project_dir.glob("*.jsonl"):
657
+ try:
658
+ mtime = jsonl.stat().st_mtime
659
+ except OSError:
660
+ continue
661
+ if now - mtime > ACTIVE_WINDOW_SECONDS:
662
+ continue
663
+ records = _tail_records(jsonl)
664
+ cwd, last_action = "", "—"
665
+ if records:
666
+ cwd = str(records[-1].get("cwd") or "")
667
+ last_action = _latest_action(records)
668
+ origin = _head_cwd(jsonl)
669
+ if not cwd and not origin:
670
+ # 兩者皆空時走 dash-還原 fallback,origin 與 cwd 退回同一值
671
+ cwd = "/" + project_dir.name.lstrip("-").replace("-", "/")
672
+ origin = cwd
673
+ elif not cwd:
674
+ # 只有 cwd 空、origin 有值時,cwd fallback 到 origin
675
+ cwd = origin
676
+ elif not origin:
677
+ # 只有 origin 空時,退回 cwd(開場 == 當下)
678
+ origin = cwd
679
+ raw.append(
680
+ Session(
681
+ session_id=jsonl.stem,
682
+ cwd=cwd,
683
+ status=Status.ENDED, # 先佔位,下面按 cwd 群組判定
684
+ last_active=mtime,
685
+ last_action=last_action,
686
+ source="scan",
687
+ todo=_extract_todo(records),
688
+ recent_actions=_recent_actions(records),
689
+ provider="claude-code",
690
+ _tail_kind=_conversation_tail_kind(records),
691
+ origin_cwd=origin,
692
+ )
693
+ )
694
+
695
+ # 按「當下 cwd」(s.cwd)分組——確保 liveness 排名母體與計數母體一致。
696
+ # counts / cwd_ttys 的鍵是 live process 回報的當下 cwd,分組鍵必須相同才能正確比對。
697
+ # 每個 cwd 群組裡,mtime 最新的 N 個=活著(N 取決於該 cwd 的 live claude 數)。
698
+ # 沒有 live process 對上的 transcript 直接維持 ENDED;若真的有活 process 但 cwd 對不上,
699
+ # _synthetic_sessions 會補一列 source="proc",不要讓舊 transcript 冒充活 session。
700
+ #
701
+ # 注意:「此 session 屬於哪個專案」由 Session.project property 讀 origin_cwd 獨立處理,
702
+ # 與這裡的 liveness 分組無關——兩者語意已分離,不需要讓分組鍵跟著改。
703
+ by_cwd: dict[str, list[Session]] = {}
704
+ for s in raw:
705
+ by_cwd.setdefault(s.cwd, []).append(s)
706
+ out: list[Session] = []
707
+ for _cwd, group in by_cwd.items():
708
+ group.sort(key=lambda s: s.last_active, reverse=True)
709
+ # 同一 cwd 組內,各 session 依自己的當下 cwd 查 live 名額與 tty
710
+ for i, s in enumerate(group):
711
+ skey = _real(s.cwd)
712
+ live_n = counts.get(skey, 0)
713
+ # 當下 cwd 只有一個 claude 時,把它的 tty 給那個活著的 session(終端跳轉用);
714
+ # 多個 claude 同 cwd 無法精準對應,留給 hook 模式處理。
715
+ uniq_tty = cwd_ttys[skey][0] if live_n == 1 and cwd_ttys.get(skey) else ""
716
+ idle = now - s.last_active
717
+ if i < live_n:
718
+ s.status = _scan_status(idle)
719
+ s.status = _apply_waiting(s.status, idle, s._tail_kind, WAITING_WINDOW_SECONDS)
720
+ if i == 0 and uniq_tty:
721
+ s.tty = uniq_tty
722
+ out.append(s)
723
+ return out
724
+
725
+
726
+ def _synthetic_sessions(procs: list[tuple[str, str]], existing: list[Session]) -> list[Session]:
727
+ """對「有 live process 卻無任何對應 row(scan + hook 都沒有)」的 cwd 合成最小資訊列。
728
+
729
+ :param procs: 每個還活著的 claude:(cwd, tty),來自 ``_claude_procs()``。
730
+ :param existing: 已由 hook + scan 產出的 session 列表(用來計算差集)。
731
+ :returns: 補列清單(每個入選 cwd 各一列,source="proc")。
732
+ """
733
+ existing_cwds = {_real(s.cwd) for s in existing}
734
+ # 先收集每個 cwd 的所有 tty(保留順序),以便取「第一個非空 tty」
735
+ cwd_ttys: dict[str, list[str]] = {}
736
+ for cwd, tty in procs:
737
+ if not cwd:
738
+ continue
739
+ cwd_ttys.setdefault(cwd, []).append(tty)
740
+
741
+ out: list[Session] = []
742
+ seen: set[str] = set()
743
+ for cwd, tty in procs:
744
+ if not cwd: # _pid_cwd 失敗的情況,沒 cwd 撐不起一列
745
+ continue
746
+ rkey = _real(cwd)
747
+ if rkey in existing_cwds: # 已經有 row 了(hook 或 scan 覆蓋)
748
+ continue
749
+ if rkey in seen: # 同 cwd 多 process 只補一列
750
+ continue
751
+ seen.add(rkey)
752
+ # 取第一個非空 tty
753
+ first_tty = next((t for t in cwd_ttys.get(cwd, []) if t), None)
754
+ out.append(
755
+ Session(
756
+ session_id=f"synthetic:{cwd}",
757
+ cwd=cwd,
758
+ status=Status.IDLE,
759
+ last_active=time.time(),
760
+ last_action="—",
761
+ source="proc",
762
+ tty=first_tty,
763
+ provider="claude-code",
764
+ origin_cwd=cwd, # synthetic 列自身就是開場,origin == 當下
765
+ )
766
+ )
767
+ return out
768
+
769
+
770
+ def _hook_sessions(
771
+ procs: list[tuple[str, str]] | None = None,
772
+ *,
773
+ procs_by_provider: dict[str, list[tuple[str, str]]] | None = None,
774
+ ) -> list[Session]:
775
+ if not RING_REGISTRY.is_dir():
776
+ return []
777
+ out: list[Session] = []
778
+ for f in RING_REGISTRY.glob("*.json"):
779
+ try:
780
+ data = json.loads(f.read_text())
781
+ except (OSError, json.JSONDecodeError):
782
+ continue
783
+ try:
784
+ todo = data.get("todo")
785
+ provider = str(data.get("provider", "claude-code") or "claude-code")
786
+ if provider in _SESSION_START_SOURCES:
787
+ # 舊版 bug 把 SessionStart 的 source(startup/resume/clear/compact)誤當
788
+ # provider,留下無 tty、跳不過去、又永不離場的幽靈列。清掉這種腐壞檔,自我修復。
789
+ f.unlink(missing_ok=True)
790
+ continue
791
+ out.append(
792
+ Session(
793
+ session_id=str(data["session_id"]),
794
+ cwd=str(data.get("cwd", "")),
795
+ status=Status(data.get("status", "idle")),
796
+ last_active=float(data.get("last_active", 0.0)),
797
+ last_action=str(data.get("last_action", "—")),
798
+ source="hook",
799
+ tty=str(data.get("tty", "")) or None,
800
+ todo=tuple(todo) if isinstance(todo, list) and len(todo) == 2 else None,
801
+ provider=provider,
802
+ origin_cwd=str(data.get("origin_cwd", "")),
803
+ )
804
+ )
805
+ except (KeyError, ValueError):
806
+ continue
807
+ # SessionEnd 沒觸發(crash)會留下幽靈檔。判定離場:
808
+ # 1. 該 cwd 完全沒有 live proc → 一定離場。
809
+ # 2. 該 cwd 的 hook row 數「多於」live proc 數(真的有多餘列要修剪)時,才用 tty
810
+ # 挑出 tty 對不上的那幾筆標離場。row 數 <= proc 數時每筆都可能還活著,不靠 tty 殺
811
+ # ——因為 hook 寫進來的 tty 不一定可靠(終端 tty 會被作業系統重配,甚至跨 session
812
+ # 錯置),拿它隱藏唯一活著的 session 會讓整列憑空消失。
813
+ if out:
814
+ proc_counts: dict[tuple[str, str], int] = {}
815
+ proc_ttys: dict[tuple[str, str], set[str]] = {}
816
+ for pk in _PROVIDER_PROCS:
817
+ provider_procs = procs_by_provider.get(pk, []) if procs_by_provider is not None else (procs or [])
818
+ for cwd, tty in provider_procs:
819
+ key = (pk, _real(cwd))
820
+ proc_counts[key] = proc_counts.get(key, 0) + 1
821
+ if tty:
822
+ proc_ttys.setdefault(key, set()).add(tty)
823
+
824
+ rows_by_key: dict[tuple[str, str], list[Session]] = {}
825
+ for s in out:
826
+ pk = _canonical_provider(s.provider)
827
+ if pk not in _PROVIDER_PROCS:
828
+ continue # 沒有 proc 偵測器 → 無法驗活性 → fail-open,交給 SessionEnd
829
+ rows_by_key.setdefault((pk, _real(s.cwd)), []).append(s)
830
+
831
+ for key, rows in rows_by_key.items():
832
+ live_n = proc_counts.get(key, 0)
833
+ if live_n == 0:
834
+ for s in rows:
835
+ s.status = Status.ENDED
836
+ continue
837
+ if len(rows) <= live_n:
838
+ continue
839
+
840
+ live_ttys = proc_ttys.get(key, set())
841
+ if live_ttys:
842
+ for s in rows:
843
+ if s.tty and s.tty not in live_ttys:
844
+ s.status = Status.ENDED
845
+
846
+ remaining = [s for s in rows if s.status is not Status.ENDED]
847
+ if len(remaining) > live_n:
848
+ remaining.sort(key=lambda s: s.last_active, reverse=True)
849
+ for s in remaining[live_n:]:
850
+ s.status = Status.ENDED
851
+ return out