agent-manager-cli 0.1.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.
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from am.scanners.claude import scan_claude_sessions
6
+ from am.scanners.codex import scan_codex_sessions
7
+ from am.scanners.gemini import scan_gemini_sessions
8
+ from am.schemas.session import LocalSession
9
+
10
+
11
+ def scan_all_sessions() -> list[LocalSession]:
12
+ """Scan all supported CLI tools and return merged session list."""
13
+ sessions: list[LocalSession] = []
14
+ sessions.extend(scan_claude_sessions())
15
+ sessions.extend(scan_codex_sessions())
16
+ sessions.extend(scan_gemini_sessions())
17
+ sessions.sort(
18
+ key=lambda s: s.updated_at or datetime.min.replace(tzinfo=UTC),
19
+ reverse=True,
20
+ )
21
+ return sessions
am/scanners/_util.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ # Max bytes to read from end of a file to avoid OOM
4
+ TAIL_BYTES = 64 * 1024 # 64 KB
5
+
6
+
7
+ def read_tail(path, max_bytes: int = TAIL_BYTES) -> str:
8
+ """Read the tail of a file, skipping partial first line."""
9
+ try:
10
+ file_size = path.stat().st_size
11
+ with open(path, "rb") as f:
12
+ if file_size > max_bytes:
13
+ f.seek(file_size - max_bytes)
14
+ f.readline() # skip partial line
15
+ return f.read().decode("utf-8", errors="replace")
16
+ except Exception:
17
+ return ""
am/scanners/claude.py ADDED
@@ -0,0 +1,105 @@
1
+ """Scanner for Claude Code CLI sessions (~/.claude/projects/)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from am.scanners._util import read_tail
11
+ from am.schemas.session import LocalSession
12
+
13
+ CLAUDE_DIR = Path.home() / ".claude" / "projects"
14
+
15
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
16
+
17
+
18
+ def _project_name(dirname: str) -> str:
19
+ parts = dirname.strip("-").split("-")
20
+ return parts[-1] if parts else dirname
21
+
22
+
23
+ def _resolve_cwd(project_dir: Path) -> str | None:
24
+ """Extract real project path from `cwd` field in JSONL metadata."""
25
+ for jsonl_file in project_dir.glob("*.jsonl"):
26
+ try:
27
+ with open(jsonl_file, encoding="utf-8") as f:
28
+ for line in f:
29
+ line = line.strip()
30
+ if not line:
31
+ continue
32
+ try:
33
+ entry = json.loads(line)
34
+ except json.JSONDecodeError:
35
+ continue
36
+ cwd = entry.get("cwd")
37
+ if cwd and isinstance(cwd, str):
38
+ return cwd
39
+ except Exception:
40
+ continue
41
+ return None
42
+
43
+
44
+ def _last_user_message(jsonl_path: Path) -> str | None:
45
+ tail = read_tail(jsonl_path)
46
+ if not tail:
47
+ return None
48
+
49
+ for line in reversed(tail.strip().splitlines()):
50
+ try:
51
+ entry = json.loads(line)
52
+ except json.JSONDecodeError:
53
+ continue
54
+ if entry.get("type") != "user":
55
+ continue
56
+ msg = entry.get("message", {})
57
+ if msg.get("role") != "user":
58
+ continue
59
+ content = msg.get("content")
60
+ if isinstance(content, str):
61
+ text = content
62
+ elif isinstance(content, list):
63
+ text = " ".join(
64
+ b.get("text", "")
65
+ for b in content
66
+ if isinstance(b, dict) and b.get("type") == "text"
67
+ )
68
+ else:
69
+ continue
70
+ text = text.strip()
71
+ if text and not text.startswith("[Request interrupted"):
72
+ return text[:200]
73
+ return None
74
+
75
+
76
+ def scan_claude_sessions() -> list[LocalSession]:
77
+ if not CLAUDE_DIR.is_dir():
78
+ return []
79
+
80
+ sessions: list[LocalSession] = []
81
+
82
+ for project_dir in CLAUDE_DIR.iterdir():
83
+ if not project_dir.is_dir():
84
+ continue
85
+
86
+ name = _project_name(project_dir.name)
87
+ cwd = _resolve_cwd(project_dir)
88
+
89
+ for f in project_dir.glob("*.jsonl"):
90
+ sid = f.stem
91
+ if not _UUID_RE.match(sid):
92
+ continue
93
+
94
+ sessions.append(
95
+ LocalSession(
96
+ session_id=sid,
97
+ project=name,
98
+ project_path=cwd or "",
99
+ cli="claude",
100
+ last_message=_last_user_message(f),
101
+ updated_at=datetime.fromtimestamp(f.stat().st_mtime, tz=UTC),
102
+ )
103
+ )
104
+
105
+ return sessions
am/scanners/codex.py ADDED
@@ -0,0 +1,88 @@
1
+ """Scanner for OpenAI Codex CLI sessions (~/.codex/sessions/)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from am.scanners._util import read_tail
11
+ from am.schemas.session import LocalSession
12
+
13
+ CODEX_DIR = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
14
+ SESSIONS_DIR = CODEX_DIR / "sessions"
15
+
16
+
17
+ def _parse_codex_session(jsonl_path: Path) -> LocalSession | None:
18
+ """Parse a Codex rollout-*.jsonl file."""
19
+ tail = read_tail(jsonl_path)
20
+ if not tail:
21
+ return None
22
+
23
+ # Extract session_id from filename: rollout-<timestamp>-<id>.jsonl
24
+ sid = jsonl_path.stem # e.g. "rollout-1234567890-abcdef"
25
+
26
+ project_path = ""
27
+ last_message = None
28
+
29
+ for line in reversed(tail.strip().splitlines()):
30
+ try:
31
+ entry = json.loads(line)
32
+ except json.JSONDecodeError:
33
+ continue
34
+
35
+ # Try to find project path from turn_context or session_meta
36
+ if not project_path:
37
+ if entry.get("type") == "session_meta":
38
+ project_path = entry.get("cwd", "")
39
+ elif entry.get("cwd"):
40
+ project_path = entry["cwd"]
41
+
42
+ # Find last user message
43
+ if not last_message:
44
+ payload = entry.get("payload", {})
45
+ if payload.get("type") == "user_message":
46
+ text = payload.get("message", "").strip()
47
+ if text:
48
+ last_message = text[:200]
49
+
50
+ # Also check event_msg wrapper
51
+ if entry.get("type") == "event_msg":
52
+ inner = entry.get("payload", {})
53
+ if inner.get("type") == "user_message":
54
+ text = inner.get("message", "").strip()
55
+ if text:
56
+ last_message = text[:200]
57
+
58
+ if project_path and last_message:
59
+ break
60
+
61
+ project_name = Path(project_path).name if project_path else sid
62
+
63
+ return LocalSession(
64
+ session_id=sid,
65
+ project=project_name,
66
+ project_path=project_path,
67
+ cli="codex",
68
+ last_message=last_message,
69
+ updated_at=datetime.fromtimestamp(jsonl_path.stat().st_mtime, tz=UTC),
70
+ )
71
+
72
+
73
+ def scan_codex_sessions() -> list[LocalSession]:
74
+ if not SESSIONS_DIR.is_dir():
75
+ return []
76
+
77
+ sessions: list[LocalSession] = []
78
+
79
+ # Sessions are in YYYY/MM/DD/ subdirectories
80
+ for jsonl_file in SESSIONS_DIR.rglob("rollout-*.jsonl"):
81
+ try:
82
+ session = _parse_codex_session(jsonl_file)
83
+ if session:
84
+ sessions.append(session)
85
+ except Exception:
86
+ continue
87
+
88
+ return sessions
am/scanners/gemini.py ADDED
@@ -0,0 +1,99 @@
1
+ """Scanner for Google Gemini CLI sessions (~/.gemini/tmp/)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from am.schemas.session import LocalSession
10
+
11
+ GEMINI_DIR = Path.home() / ".gemini" / "tmp"
12
+
13
+
14
+ def _parse_gemini_project(project_dir: Path) -> list[LocalSession]:
15
+ """Parse sessions from a Gemini project hash directory."""
16
+ chats_dir = project_dir / "chats"
17
+ if not chats_dir.is_dir():
18
+ return []
19
+
20
+ sessions: list[LocalSession] = []
21
+
22
+ # Try to find project path from logs.json
23
+ project_path = ""
24
+ logs_file = chats_dir / "logs.json"
25
+ if logs_file.is_file():
26
+ try:
27
+ data = json.loads(logs_file.read_text(encoding="utf-8"))
28
+ # logs.json may contain cwd or project root info
29
+ if isinstance(data, dict):
30
+ project_path = data.get("cwd", "")
31
+ except Exception:
32
+ pass
33
+
34
+ project_name = Path(project_path).name if project_path else project_dir.name[:12]
35
+
36
+ # Scan JSON files in chats/ directory
37
+ for chat_file in chats_dir.glob("*.json"):
38
+ if chat_file.name == "logs.json":
39
+ continue
40
+ try:
41
+ stat = chat_file.stat()
42
+ last_message = None
43
+
44
+ # Try to extract last user message from chat JSON
45
+ try:
46
+ data = json.loads(chat_file.read_text(encoding="utf-8"))
47
+ messages = []
48
+ if isinstance(data, list):
49
+ messages = data
50
+ elif isinstance(data, dict):
51
+ messages = data.get("messages", [])
52
+ if not project_path:
53
+ project_path = data.get("cwd", "")
54
+
55
+ for msg in reversed(messages):
56
+ if msg.get("role") == "user":
57
+ content = msg.get("content", "")
58
+ if isinstance(content, list):
59
+ content = " ".join(
60
+ p.get("text", "") for p in content if isinstance(p, dict)
61
+ )
62
+ text = str(content).strip()
63
+ if text:
64
+ last_message = text[:200]
65
+ break
66
+ except Exception:
67
+ pass
68
+
69
+ sessions.append(
70
+ LocalSession(
71
+ session_id=chat_file.stem,
72
+ project=project_name,
73
+ project_path=project_path,
74
+ cli="gemini",
75
+ last_message=last_message,
76
+ updated_at=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
77
+ )
78
+ )
79
+ except Exception:
80
+ continue
81
+
82
+ return sessions
83
+
84
+
85
+ def scan_gemini_sessions() -> list[LocalSession]:
86
+ if not GEMINI_DIR.is_dir():
87
+ return []
88
+
89
+ sessions: list[LocalSession] = []
90
+
91
+ for project_dir in GEMINI_DIR.iterdir():
92
+ if not project_dir.is_dir():
93
+ continue
94
+ try:
95
+ sessions.extend(_parse_gemini_project(project_dir))
96
+ except Exception:
97
+ continue
98
+
99
+ return sessions
am/schemas/__init__.py ADDED
File without changes
am/schemas/session.py ADDED
@@ -0,0 +1,12 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class LocalSession(BaseModel):
7
+ session_id: str
8
+ project: str
9
+ project_path: str
10
+ cli: str # "claude", "codex", "gemini"
11
+ last_message: str | None = None
12
+ updated_at: datetime | None = None