agenthud 0.1.5__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.
agenthud/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Cross-tool agent session HUD."""
2
+
3
+ __version__ = "0.1.5"
agenthud/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from agenthud.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1 @@
1
+ """Adapter package."""
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from agenthud.models import Session, Status, Tool
8
+ from agenthud.paths import home, project_basename
9
+
10
+
11
+ def _projects_root() -> Path:
12
+ return home() / ".claude" / "projects"
13
+
14
+
15
+ def _infer(last_role: str | None, mtime: float, now: float) -> tuple[Status, str]:
16
+ age = now - mtime
17
+ if last_role == "user":
18
+ if age < 600:
19
+ return Status.WAITING, "awaiting reply"
20
+ return Status.IDLE, "last: user"
21
+ if last_role == "assistant":
22
+ if age < 45:
23
+ return Status.RUNNING, "recent activity"
24
+ if age < 3600:
25
+ return Status.IDLE, "last: assistant"
26
+ return Status.DONE, "stale"
27
+ if age < 60:
28
+ return Status.RUNNING, "recent"
29
+ return Status.UNKNOWN, ""
30
+
31
+
32
+ def _scan_jsonl(path: Path) -> tuple[str | None, str]:
33
+ try:
34
+ size = path.stat().st_size
35
+ with path.open("rb") as f:
36
+ if size > 96_000:
37
+ f.seek(-96_000, 2)
38
+ raw = f.read().decode("utf-8", errors="replace")
39
+ except OSError:
40
+ return None, ""
41
+ last_role = None
42
+ title = ""
43
+ for line in raw.splitlines():
44
+ line = line.strip()
45
+ if not line:
46
+ continue
47
+ try:
48
+ obj = json.loads(line)
49
+ except json.JSONDecodeError:
50
+ continue
51
+ role = obj.get("role") or obj.get("type")
52
+ if role in {"user", "assistant", "human"}:
53
+ last_role = "user" if role in {"user", "human"} else "assistant"
54
+ msg = obj.get("message") or obj.get("content") or ""
55
+ text = ""
56
+ if isinstance(msg, str):
57
+ text = msg
58
+ elif isinstance(msg, dict):
59
+ c = msg.get("content")
60
+ if isinstance(c, str):
61
+ text = c
62
+ elif isinstance(c, list):
63
+ for part in c:
64
+ if isinstance(part, dict) and part.get("type") == "text":
65
+ text = str(part.get("text") or "")
66
+ break
67
+ if last_role == "user" and text:
68
+ title = " ".join(text.split())[:80]
69
+ return last_role, title
70
+
71
+
72
+ def collect(*, limit: int = 40, max_age_hours: float = 72) -> list[Session]:
73
+ root = _projects_root()
74
+ if not root.is_dir():
75
+ return []
76
+ now = time.time()
77
+ cutoff = now - max_age_hours * 3600
78
+ found: list[tuple[float, Path, Path]] = []
79
+ for project in root.iterdir():
80
+ if not project.is_dir():
81
+ continue
82
+ for path in project.glob("*.jsonl"):
83
+ try:
84
+ mtime = path.stat().st_mtime
85
+ except OSError:
86
+ continue
87
+ if mtime < cutoff:
88
+ continue
89
+ found.append((mtime, project, path))
90
+ found.sort(key=lambda t: t[0], reverse=True)
91
+
92
+ sessions: list[Session] = []
93
+ for mtime, project, path in found[:limit]:
94
+ last_role, title = _scan_jsonl(path)
95
+ status, detail = _infer(last_role, mtime, now)
96
+ sessions.append(
97
+ Session(
98
+ id=path.stem,
99
+ tool=Tool.CLAUDE,
100
+ title=title or path.stem[:8],
101
+ status=status,
102
+ project=project_basename(project.name.replace("-", "/")),
103
+ updated_at=mtime,
104
+ detail=detail,
105
+ path=str(path),
106
+ )
107
+ )
108
+ return sessions
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from agenthud.models import Session, Status, Tool
8
+ from agenthud.paths import home, project_basename
9
+
10
+
11
+ def _codex_root() -> Path:
12
+ return home() / ".codex" / "sessions"
13
+
14
+
15
+ def _parse_iso(ts: str | None) -> float:
16
+ if not ts:
17
+ return 0.0
18
+ try:
19
+ # 2026-09-19T13:53:13.175Z
20
+ from datetime import datetime
21
+
22
+ return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
23
+ except ValueError:
24
+ return 0.0
25
+
26
+
27
+ def _title_and_status(path: Path) -> tuple[str, Status, str, float]:
28
+ title = path.stem
29
+ status = Status.IDLE
30
+ detail = ""
31
+ updated = path.stat().st_mtime
32
+ cwd = ""
33
+ try:
34
+ size = path.stat().st_size
35
+ with path.open("rb") as f:
36
+ if size > 120_000:
37
+ f.seek(-120_000, 2)
38
+ raw = f.read().decode("utf-8", errors="replace")
39
+ except OSError:
40
+ return title, Status.UNKNOWN, "", updated
41
+
42
+ for line in raw.splitlines():
43
+ line = line.strip()
44
+ if not line:
45
+ continue
46
+ try:
47
+ obj = json.loads(line)
48
+ except json.JSONDecodeError:
49
+ continue
50
+ ts = _parse_iso(obj.get("timestamp"))
51
+ if ts:
52
+ updated = max(updated, ts)
53
+ typ = obj.get("type")
54
+ payload = obj.get("payload") or {}
55
+ if typ == "session_meta":
56
+ cwd = str((payload.get("cwd") or payload.get("directory") or ""))
57
+ if payload.get("title"):
58
+ title = str(payload["title"])
59
+ if typ == "event_msg":
60
+ et = payload.get("type")
61
+ if et == "task_complete":
62
+ err = payload.get("error") or {}
63
+ if err:
64
+ status = Status.ERROR
65
+ detail = str(err.get("message") or err.get("codex_error_info") or "error")[:80]
66
+ else:
67
+ status = Status.DONE
68
+ detail = "complete"
69
+ item = payload.get("item") or {}
70
+ if item.get("type") == "UserMessage" and not title.startswith("rollout"):
71
+ pass
72
+ if item.get("type") == "UserMessage":
73
+ content = item.get("content") or []
74
+ for part in content:
75
+ if isinstance(part, dict) and part.get("type") == "text":
76
+ text = " ".join(str(part.get("text") or "").split())
77
+ if text:
78
+ title = text[:80]
79
+
80
+ age = time.time() - updated
81
+ if status == Status.IDLE and age < 90:
82
+ status = Status.RUNNING
83
+ detail = "recent"
84
+ elif status == Status.IDLE and age > 3600 * 6:
85
+ status = Status.DONE
86
+ detail = detail or "stale"
87
+ return title, status, detail, updated
88
+
89
+
90
+ def collect(*, limit: int = 40, max_age_hours: float = 72) -> list[Session]:
91
+ root = _codex_root()
92
+ if not root.is_dir():
93
+ return []
94
+ cutoff = time.time() - max_age_hours * 3600
95
+ files: list[Path] = []
96
+ for path in root.rglob("rollout-*.jsonl"):
97
+ try:
98
+ if path.stat().st_mtime >= cutoff:
99
+ files.append(path)
100
+ except OSError:
101
+ continue
102
+ files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
103
+
104
+ sessions: list[Session] = []
105
+ for path in files[:limit]:
106
+ # rollout-...-<uuid>.jsonl
107
+ sid = path.stem
108
+ if "rollout-" in sid:
109
+ parts = sid.split("-")
110
+ # uuid often last 5 segments
111
+ sid = "-".join(parts[-5:]) if len(parts) >= 5 else sid
112
+ title, status, detail, updated = _title_and_status(path)
113
+ # try cwd from first bytes only already in parse — re-read meta lightly
114
+ project = project_basename(str(path.parent))
115
+ sessions.append(
116
+ Session(
117
+ id=sid,
118
+ tool=Tool.CODEX,
119
+ title=title,
120
+ status=status,
121
+ project=project,
122
+ updated_at=updated,
123
+ detail=detail,
124
+ path=str(path),
125
+ )
126
+ )
127
+ return sessions
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from agenthud.models import Session, Status, Tool
9
+ from agenthud.paths import cursor_projects_dir
10
+
11
+ _USER_QUERY_RE = re.compile(
12
+ r"<user_query>\s*(.*?)\s*</user_query>",
13
+ re.DOTALL | re.IGNORECASE,
14
+ )
15
+
16
+
17
+ def _project_label(project_dir: Path) -> str:
18
+ """Map Cursor project folder slug to a short label."""
19
+ name = project_dir.name
20
+ # Users-ayaan-Documents-Default-Project → Default Project
21
+ for prefix in (
22
+ "Users-ayaan-Documents-",
23
+ "Users-ayaan-",
24
+ "Users-",
25
+ ):
26
+ if name.startswith(prefix):
27
+ name = name[len(prefix) :]
28
+ break
29
+ return name.replace("-", " ")
30
+
31
+
32
+ def _infer_status(last_role: str | None, mtime: float, now: float) -> tuple[Status, str]:
33
+ age = now - mtime
34
+ if last_role == "user":
35
+ if age < 600:
36
+ return Status.WAITING, "awaiting agent reply"
37
+ return Status.IDLE, "last: user"
38
+ if last_role == "assistant":
39
+ if age < 45:
40
+ return Status.RUNNING, "recent assistant activity"
41
+ if age < 3600:
42
+ return Status.IDLE, "last: assistant"
43
+ return Status.DONE, "stale"
44
+ if age < 60:
45
+ return Status.RUNNING, "recently updated"
46
+ return Status.UNKNOWN, ""
47
+
48
+
49
+ def _text_from_message(obj: dict) -> str:
50
+ msg = obj.get("message") or {}
51
+ content = msg.get("content")
52
+ if isinstance(content, list):
53
+ parts: list[str] = []
54
+ for part in content:
55
+ if isinstance(part, dict) and part.get("type") == "text":
56
+ parts.append(str(part.get("text") or ""))
57
+ return "\n".join(parts)
58
+ if isinstance(content, str):
59
+ return content
60
+ return ""
61
+
62
+
63
+ def _title_from_text(text: str) -> str:
64
+ matches = _USER_QUERY_RE.findall(text)
65
+ if matches:
66
+ text = matches[-1]
67
+ else:
68
+ # drop injected timestamp / websearch blobs
69
+ text = re.sub(r"<timestamp>.*?</timestamp>", "", text, flags=re.DOTALL)
70
+ text = re.sub(r"<external_links>.*", "", text, flags=re.DOTALL | re.IGNORECASE)
71
+ text = " ".join(text.split())
72
+ return text[:80]
73
+
74
+
75
+ def _last_role_and_snippet(path: Path) -> tuple[str | None, str]:
76
+ """Read last few KB of jsonl for last role + last user_query title."""
77
+ try:
78
+ size = path.stat().st_size
79
+ with path.open("rb") as f:
80
+ if size > 96_000:
81
+ f.seek(-96_000, 2)
82
+ raw = f.read().decode("utf-8", errors="replace")
83
+ except OSError:
84
+ return None, ""
85
+
86
+ last_role: str | None = None
87
+ title = ""
88
+ for line in raw.splitlines():
89
+ line = line.strip()
90
+ if not line:
91
+ continue
92
+ try:
93
+ obj = json.loads(line)
94
+ except json.JSONDecodeError:
95
+ continue
96
+ role = obj.get("role")
97
+ if role:
98
+ last_role = role
99
+ if role == "user":
100
+ candidate = _title_from_text(_text_from_message(obj))
101
+ if candidate:
102
+ title = candidate
103
+
104
+ return last_role, title
105
+
106
+
107
+ def collect(*, limit: int = 40, max_age_hours: float = 72) -> list[Session]:
108
+ root = cursor_projects_dir()
109
+ if not root.is_dir():
110
+ return []
111
+
112
+ now = time.time()
113
+ cutoff = now - max_age_hours * 3600
114
+ found: list[tuple[float, Path, Path]] = []
115
+
116
+ for project in root.iterdir():
117
+ transcripts = project / "agent-transcripts"
118
+ if not transcripts.is_dir():
119
+ continue
120
+ for entry in transcripts.iterdir():
121
+ jsonl = entry / f"{entry.name}.jsonl" if entry.is_dir() else entry
122
+ if not jsonl.name.endswith(".jsonl"):
123
+ # also accept flat uuid.jsonl under agent-transcripts
124
+ if entry.is_file() and entry.suffix == ".jsonl":
125
+ jsonl = entry
126
+ else:
127
+ continue
128
+ if not jsonl.is_file():
129
+ continue
130
+ try:
131
+ mtime = jsonl.stat().st_mtime
132
+ except OSError:
133
+ continue
134
+ if mtime < cutoff:
135
+ continue
136
+ found.append((mtime, project, jsonl))
137
+
138
+ found.sort(key=lambda t: t[0], reverse=True)
139
+ sessions: list[Session] = []
140
+ for mtime, project, jsonl in found[:limit]:
141
+ last_role, snippet = _last_role_and_snippet(jsonl)
142
+ status, detail = _infer_status(last_role, mtime, now)
143
+ sid = jsonl.stem
144
+ title = snippet or sid[:8]
145
+ sessions.append(
146
+ Session(
147
+ id=sid,
148
+ tool=Tool.CURSOR,
149
+ title=title,
150
+ status=status,
151
+ project=_project_label(project),
152
+ updated_at=mtime,
153
+ detail=detail,
154
+ path=str(jsonl),
155
+ extra={"project_dir": str(project)},
156
+ )
157
+ )
158
+ return sessions
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from agenthud.models import Session, Status, Tool
9
+ from agenthud.paths import freebuff_config_dir, project_basename
10
+
11
+
12
+ def _ms_to_s(value: int | float | None) -> float:
13
+ if value is None:
14
+ return 0.0
15
+ v = float(value)
16
+ return v / 1000.0 if v > 1_000_000_000_000 else v
17
+
18
+
19
+ def _map_status(
20
+ turn_state: str,
21
+ attention_reason: str | None,
22
+ last_outcome: str | None,
23
+ attention_rev: int,
24
+ attention_ack: int,
25
+ ) -> tuple[Status, str]:
26
+ ts = (turn_state or "idle").lower()
27
+ reason = (attention_reason or "").lower()
28
+ outcome = (last_outcome or "").lower()
29
+ needs_attention = attention_rev > attention_ack
30
+
31
+ if ts in {"running", "working", "streaming", "thinking"}:
32
+ return Status.RUNNING, ts
33
+ if ts in {"waiting", "blocked", "needs_input", "ask_user"}:
34
+ return Status.WAITING, ts
35
+ if outcome in {"error", "failed"} or reason in {"failed", "error"}:
36
+ return Status.ERROR, reason or outcome or "error"
37
+ if needs_attention and reason:
38
+ if reason in {"finished", "completed"}:
39
+ return Status.DONE, f"attention: {reason}"
40
+ if reason in {"failed", "error"}:
41
+ return Status.ERROR, f"attention: {reason}"
42
+ return Status.WAITING, f"attention: {reason}"
43
+ if outcome in {"completed", "finished"} or reason in {"finished", "completed"}:
44
+ return Status.DONE, outcome or reason
45
+ if outcome == "stopped" or reason == "stopped":
46
+ return Status.IDLE, "stopped"
47
+ if ts == "idle":
48
+ return Status.IDLE, ""
49
+ return Status.UNKNOWN, ts
50
+
51
+
52
+ def _active_tab_ids(config: Path) -> set[str]:
53
+ state_path = config / "state.json"
54
+ if not state_path.is_file():
55
+ return set()
56
+ try:
57
+ data = json.loads(state_path.read_text(encoding="utf-8"))
58
+ except (OSError, json.JSONDecodeError):
59
+ return set()
60
+ tabs = (data.get("workspace") or {}).get("tabs") or []
61
+ return {str(t.get("id")) for t in tabs if isinstance(t, dict) and t.get("id")}
62
+
63
+
64
+ def _collect_from_db(db_path: Path, *, limit: int, cutoff_ms: int, open_tabs: set[str]) -> list[Session]:
65
+ try:
66
+ uri = f"file:{db_path}?mode=ro"
67
+ conn = sqlite3.connect(uri, uri=True, timeout=1.0)
68
+ conn.row_factory = sqlite3.Row
69
+ rows = conn.execute(
70
+ """
71
+ SELECT id, title, project_path, turn_state, status,
72
+ attention_reason, attention_revision, attention_acknowledged_revision,
73
+ last_turn_outcome, last_prompt_at, last_turn_finished_at, updated_at,
74
+ model, agent_mode
75
+ FROM threads
76
+ WHERE updated_at >= ?
77
+ ORDER BY updated_at DESC
78
+ LIMIT ?
79
+ """,
80
+ (cutoff_ms, limit),
81
+ ).fetchall()
82
+ conn.close()
83
+ except sqlite3.Error:
84
+ return []
85
+
86
+ now = time.time()
87
+ out: list[Session] = []
88
+ for row in rows:
89
+ status, detail = _map_status(
90
+ str(row["turn_state"] or "idle"),
91
+ row["attention_reason"],
92
+ row["last_turn_outcome"],
93
+ int(row["attention_revision"] or 0),
94
+ int(row["attention_acknowledged_revision"] or 0),
95
+ )
96
+ # Open workspace tabs that are idle still matter — mark as open
97
+ sid = str(row["id"])
98
+ if sid in open_tabs and status in {Status.IDLE, Status.DONE, Status.UNKNOWN}:
99
+ detail = (detail + " · open tab").strip(" ·")
100
+ model = row["model"] or row["agent_mode"] or ""
101
+ if model:
102
+ detail = f"{detail} · {model}".strip(" ·")
103
+ updated = _ms_to_s(row["updated_at"])
104
+ # Freshly running heuristic: turn_alive / recent prompt
105
+ if status == Status.IDLE:
106
+ prompt_at = _ms_to_s(row["last_prompt_at"])
107
+ finished = _ms_to_s(row["last_turn_finished_at"])
108
+ if prompt_at and prompt_at > finished and (now - prompt_at) < 180:
109
+ status = Status.RUNNING
110
+ detail = "prompt in flight"
111
+ out.append(
112
+ Session(
113
+ id=sid,
114
+ tool=Tool.FREEBUFF,
115
+ title=str(row["title"] or sid[:8]),
116
+ status=status,
117
+ project=project_basename(str(row["project_path"] or "")),
118
+ updated_at=updated,
119
+ detail=detail,
120
+ path=str(row["project_path"] or ""),
121
+ extra={"db": str(db_path), "thread_status": row["status"]},
122
+ )
123
+ )
124
+ return out
125
+
126
+
127
+ def collect(*, limit: int = 40, max_age_hours: float = 72) -> list[Session]:
128
+ config = freebuff_config_dir()
129
+ projects = config / "projects"
130
+ if not projects.is_dir():
131
+ return []
132
+
133
+ now = time.time()
134
+ cutoff_ms = int((now - max_age_hours * 3600) * 1000)
135
+ open_tabs = _active_tab_ids(config)
136
+
137
+ sessions: list[Session] = []
138
+ for project_dir in projects.iterdir():
139
+ db = project_dir / "desktop-v2.db"
140
+ if not db.is_file():
141
+ continue
142
+ sessions.extend(
143
+ _collect_from_db(db, limit=limit, cutoff_ms=cutoff_ms, open_tabs=open_tabs)
144
+ )
145
+
146
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
147
+ return sessions[:limit]
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from agenthud.models import Session, Status, Tool
9
+ from agenthud.paths import opencode_db, project_basename
10
+
11
+
12
+ def _model_label(raw: str | None) -> str:
13
+ if not raw:
14
+ return ""
15
+ try:
16
+ obj = json.loads(raw)
17
+ if isinstance(obj, dict):
18
+ return str(obj.get("id") or obj.get("model") or obj.get("providerID") or "")
19
+ except json.JSONDecodeError:
20
+ pass
21
+ return raw if len(raw) < 40 else ""
22
+
23
+
24
+ def _ms_to_s(value: int | float | None) -> float:
25
+ if value is None:
26
+ return 0.0
27
+ v = float(value)
28
+ # OpenCode stores ms epoch
29
+ return v / 1000.0 if v > 1_000_000_000_000 else v
30
+
31
+
32
+ def _infer_status(updated_s: float, now: float, archived: int | None) -> tuple[Status, str]:
33
+ if archived:
34
+ return Status.DONE, "archived"
35
+ age = now - updated_s
36
+ if age < 90:
37
+ return Status.RUNNING, "recently updated"
38
+ if age < 3600 * 6:
39
+ return Status.IDLE, ""
40
+ return Status.DONE, "stale"
41
+
42
+
43
+ def collect(*, limit: int = 40, max_age_hours: float = 72) -> list[Session]:
44
+ db_path = opencode_db()
45
+ if not db_path.is_file():
46
+ return []
47
+
48
+ now = time.time()
49
+ cutoff_ms = int((now - max_age_hours * 3600) * 1000)
50
+ sessions: list[Session] = []
51
+
52
+ try:
53
+ # immutable + timeout so we don't block a running OpenCode
54
+ uri = f"file:{db_path}?mode=ro"
55
+ conn = sqlite3.connect(uri, uri=True, timeout=1.0)
56
+ conn.row_factory = sqlite3.Row
57
+ rows = conn.execute(
58
+ """
59
+ SELECT id, title, directory, time_updated, time_created, time_archived,
60
+ summary_files, agent, model
61
+ FROM session
62
+ WHERE parent_id IS NULL
63
+ AND time_updated >= ?
64
+ ORDER BY time_updated DESC
65
+ LIMIT ?
66
+ """,
67
+ (cutoff_ms, limit),
68
+ ).fetchall()
69
+ conn.close()
70
+ except sqlite3.Error:
71
+ return []
72
+
73
+ for row in rows:
74
+ updated = _ms_to_s(row["time_updated"])
75
+ status, detail = _infer_status(updated, now, row["time_archived"])
76
+ files = row["summary_files"]
77
+ if files and not detail:
78
+ detail = f"{files} files touched"
79
+ model = _model_label(row["model"]) or (row["agent"] or "")
80
+ if model and detail:
81
+ detail = f"{detail} · {model}"
82
+ elif model:
83
+ detail = str(model)
84
+ sessions.append(
85
+ Session(
86
+ id=str(row["id"]),
87
+ tool=Tool.OPENCODE,
88
+ title=str(row["title"] or row["id"]),
89
+ status=status,
90
+ project=project_basename(str(row["directory"] or "")),
91
+ updated_at=updated,
92
+ detail=detail,
93
+ path=str(row["directory"] or ""),
94
+ extra={"db": str(db_path)},
95
+ )
96
+ )
97
+ return sessions
@@ -0,0 +1,9 @@
1
+ """Adapters that return [] until session format is wired — still listed in Settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agenthud.models import Session
6
+
7
+
8
+ def collect(*, limit: int = 40, max_age_hours: float = 72) -> list[Session]:
9
+ return []
agenthud/aggregate.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from agenthud.adapters import claude, codex, cursor, freebuff, opencode, stub
4
+ from agenthud.models import STATUS_ORDER, Session, Tool
5
+
6
+ _COLLECTORS = {
7
+ Tool.CURSOR: cursor.collect,
8
+ Tool.OPENCODE: opencode.collect,
9
+ Tool.FREEBUFF: freebuff.collect,
10
+ Tool.CODEX: codex.collect,
11
+ Tool.CLAUDE: claude.collect,
12
+ Tool.GEMINI: stub.collect,
13
+ Tool.COPILOT: stub.collect,
14
+ Tool.AMP: stub.collect,
15
+ Tool.FACTORY: stub.collect,
16
+ Tool.PI: stub.collect,
17
+ Tool.KIRO: stub.collect,
18
+ Tool.CLINE: stub.collect,
19
+ Tool.ROO: stub.collect,
20
+ }
21
+
22
+
23
+ def collect_all(
24
+ *,
25
+ tools: set[Tool] | None = None,
26
+ limit_per_tool: int = 30,
27
+ max_age_hours: float = 72,
28
+ ) -> list[Session]:
29
+ tools = tools or set(_COLLECTORS)
30
+ sessions: list[Session] = []
31
+ for tool in tools:
32
+ fn = _COLLECTORS.get(tool)
33
+ if not fn:
34
+ continue
35
+ sessions.extend(fn(limit=limit_per_tool, max_age_hours=max_age_hours))
36
+ sessions.sort(key=lambda s: (STATUS_ORDER.get(s.status, 99), -s.updated_at))
37
+ return sessions