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 +3 -0
- agenthud/__main__.py +4 -0
- agenthud/adapters/__init__.py +1 -0
- agenthud/adapters/claude.py +108 -0
- agenthud/adapters/codex.py +127 -0
- agenthud/adapters/cursor.py +158 -0
- agenthud/adapters/freebuff.py +147 -0
- agenthud/adapters/opencode.py +97 -0
- agenthud/adapters/stub.py +9 -0
- agenthud/aggregate.py +37 -0
- agenthud/assets/popover.html +492 -0
- agenthud/assets/settings.html +285 -0
- agenthud/cli.py +102 -0
- agenthud/focus.py +105 -0
- agenthud/install.py +201 -0
- agenthud/menubar.py +8 -0
- agenthud/models.py +59 -0
- agenthud/native_menubar.py +599 -0
- agenthud/paths.py +36 -0
- agenthud/registry.py +105 -0
- agenthud/settings.py +104 -0
- agenthud/tui.py +119 -0
- agenthud-0.1.5.dist-info/METADATA +184 -0
- agenthud-0.1.5.dist-info/RECORD +27 -0
- agenthud-0.1.5.dist-info/WHEEL +4 -0
- agenthud-0.1.5.dist-info/entry_points.txt +2 -0
- agenthud-0.1.5.dist-info/licenses/LICENSE +21 -0
agenthud/__init__.py
ADDED
agenthud/__main__.py
ADDED
|
@@ -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
|
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
|