session-compass 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,3 @@
1
+ """session-compass: list and resume Claude Code, Codex, Antigravity, and Copilot CLI sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Service-specific coding agent integrations."""
2
+
3
+ from .registry import get_adapter, get_adapters
4
+
5
+ __all__ = ["get_adapter", "get_adapters"]
@@ -0,0 +1,173 @@
1
+ """Antigravity session discovery and resume integration."""
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .base import (
9
+ AgentAdapter,
10
+ AgentCompatibility,
11
+ ContractReport,
12
+ classify_resumability,
13
+ file_mtime,
14
+ parse_timestamp,
15
+ read_jsonl_lines,
16
+ )
17
+
18
+
19
+ HOME = Path.home()
20
+ AGY_DIR = HOME / ".gemini" / "antigravity-cli"
21
+ AGY_CONVERSATIONS_DIR = AGY_DIR / "conversations"
22
+ AGY_HISTORY_FILE = AGY_DIR / "history.jsonl"
23
+ AGY_METADATA_FILE = AGY_DIR / "cache" / "conversation_metadata.json"
24
+ AGY_SUMMARIES_DB = AGY_DIR / "conversation_summaries.db"
25
+
26
+
27
+ def count_steps(db_file: Path) -> int:
28
+ try:
29
+ connection = sqlite3.connect(f"file:{db_file}?mode=ro", uri=True)
30
+ try:
31
+ row = connection.execute("SELECT count(*) FROM steps").fetchone()
32
+ finally:
33
+ connection.close()
34
+ return int(row[0]) if row else 0
35
+ except sqlite3.Error:
36
+ return 0
37
+
38
+
39
+ def read_summary_catalog(path: Path) -> dict[str, dict[str, Any]]:
40
+ """Read Antigravity's user-facing conversation catalog without writing to it."""
41
+ try:
42
+ connection = sqlite3.connect(f"file:{path.resolve()}?mode=ro", uri=True)
43
+ try:
44
+ rows = connection.execute(
45
+ """
46
+ SELECT conversation_id, title, preview, workspace_uris, last_modified_time
47
+ FROM conversation_summaries
48
+ """
49
+ ).fetchall()
50
+ finally:
51
+ connection.close()
52
+ except (OSError, sqlite3.Error):
53
+ return {}
54
+
55
+ catalog: dict[str, dict[str, Any]] = {}
56
+ for conversation_id, title, preview, workspace_uris, last_modified_time in rows:
57
+ try:
58
+ parsed_workspaces = json.loads(workspace_uris or "[]")
59
+ except (TypeError, json.JSONDecodeError):
60
+ parsed_workspaces = []
61
+ if not isinstance(parsed_workspaces, list):
62
+ parsed_workspaces = []
63
+ catalog[str(conversation_id)] = {
64
+ "title": str(title or "").strip(),
65
+ "preview": str(preview or "").strip(),
66
+ "workspace_uris": [str(value) for value in parsed_workspaces if value],
67
+ "last_modified_time": last_modified_time,
68
+ }
69
+ return catalog
70
+
71
+
72
+ def read_conversation_metadata(path: Path) -> dict[str, dict[str, Any]]:
73
+ try:
74
+ data = json.loads(path.read_text(encoding="utf-8"))
75
+ except (OSError, json.JSONDecodeError, TypeError):
76
+ return {}
77
+ conversations = data.get("conversations", {}) if isinstance(data, dict) else {}
78
+ return conversations if isinstance(conversations, dict) else {}
79
+
80
+
81
+ class AntigravityAdapter:
82
+ name = "agy"
83
+ executable = "agy"
84
+ required_help_flags = ("--conversation", "--dangerously-skip-permissions")
85
+
86
+ def check_storage_contract(self, path: Path) -> ContractReport:
87
+ try:
88
+ connection = sqlite3.connect(f"file:{path.resolve()}?mode=ro", uri=True)
89
+ try:
90
+ rows = connection.execute("PRAGMA table_info(steps)").fetchall()
91
+ finally:
92
+ connection.close()
93
+ except (OSError, sqlite3.Error):
94
+ return ContractReport(self.name, False, ("steps",))
95
+ if not rows:
96
+ return ContractReport(self.name, False, ("steps",))
97
+ columns = {row[1] for row in rows}
98
+ missing = ("steps.idx",) if "idx" not in columns else ()
99
+ return ContractReport(self.name, not missing, missing)
100
+
101
+ def collect_sessions(self) -> list[dict[str, Any]]:
102
+ sessions = []
103
+ if not AGY_CONVERSATIONS_DIR.is_dir():
104
+ return sessions
105
+
106
+ from_history: dict[str, dict[str, Any]] = {}
107
+ for entry in read_jsonl_lines(AGY_HISTORY_FILE):
108
+ conversation_id = entry.get("conversationId")
109
+ if not conversation_id:
110
+ continue
111
+ record = from_history.setdefault(str(conversation_id), {})
112
+ if entry.get("workspace"):
113
+ record["workspace"] = entry["workspace"]
114
+ if entry.get("type") != "slash_command" and str(entry.get("display", "")).strip():
115
+ record["summary"] = entry["display"]
116
+ timestamp = entry.get("timestamp")
117
+ if timestamp and (not record.get("last_active") or timestamp > record["last_active"]):
118
+ record["last_active"] = timestamp
119
+
120
+ metadata = read_conversation_metadata(AGY_METADATA_FILE)
121
+ catalog = read_summary_catalog(AGY_SUMMARIES_DB)
122
+
123
+ for db_file in AGY_CONVERSATIONS_DIR.glob("*.db"):
124
+ if count_steps(db_file) == 0:
125
+ continue
126
+ session_id = db_file.stem
127
+ catalog_record = catalog.get(session_id)
128
+ if not catalog_record or metadata.get(session_id, {}).get("is_internal") is True:
129
+ continue
130
+ history = from_history.get(session_id, {})
131
+ workspace_uris = catalog_record["workspace_uris"]
132
+ cwd = (
133
+ workspace_uris[0].removeprefix("file://") if workspace_uris else None
134
+ ) or history.get("workspace")
135
+ last_active = (
136
+ parse_timestamp(
137
+ history.get("last_active") or catalog_record.get("last_modified_time")
138
+ )
139
+ or file_mtime(db_file)
140
+ )
141
+ summary = catalog_record["title"] or catalog_record["preview"]
142
+ if not summary or not cwd:
143
+ continue
144
+ session = {
145
+ "tool": self.name,
146
+ "id": session_id,
147
+ "cwd": cwd,
148
+ "summary": summary,
149
+ "last_active": last_active,
150
+ "record_kind": "conversation",
151
+ "has_conversation_content": True,
152
+ "summary_source": "catalog_title" if catalog_record["title"] else "catalog_preview",
153
+ "workspace_source": "catalog_workspace_uri" if workspace_uris else "history_workspace",
154
+ }
155
+ session["resume_status"] = classify_resumability(session).value
156
+ sessions.append(session)
157
+ return sessions
158
+
159
+ def build_resume_command(self, session_id: str, dangerous: bool = False) -> list[str]:
160
+ command = ["agy"]
161
+ if dangerous:
162
+ command.append("--dangerously-skip-permissions")
163
+ return command + ["--conversation", session_id]
164
+
165
+ def compatibility(self) -> AgentCompatibility:
166
+ return AgentCompatibility(self.name, "1.1.27", "1.1.27", "1.1.27")
167
+
168
+
169
+ ADAPTER: AgentAdapter = AntigravityAdapter()
170
+
171
+
172
+ def collect_sessions() -> list[dict[str, Any]]:
173
+ return ADAPTER.collect_sessions()
@@ -0,0 +1,141 @@
1
+ """Shared types and helpers for service-specific session adapters."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any, Optional, Protocol, runtime_checkable
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AgentCompatibility:
13
+ agent: str
14
+ resume_min_version: str
15
+ storage_min_version: str
16
+ tested_latest_version: Optional[str] = None
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ProbeResult:
21
+ command: tuple[str, ...]
22
+ returncode: int
23
+ stdout: str
24
+ stderr: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CapabilityReport:
29
+ agent: str
30
+ version: Optional[str]
31
+ resume: bool
32
+ dangerous_resume: bool
33
+ detected_flags: tuple[str, ...]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ContractReport:
38
+ agent: str
39
+ ok: bool
40
+ missing: tuple[str, ...] = ()
41
+ warnings: tuple[str, ...] = ()
42
+
43
+
44
+ class ResumeStatus(Enum):
45
+ RESUMABLE = "resumable"
46
+ LIKELY_RESUMABLE = "likely_resumable"
47
+ METADATA_ONLY = "metadata_only"
48
+ INVALID = "invalid"
49
+
50
+
51
+ @runtime_checkable
52
+ class AgentAdapter(Protocol):
53
+ name: str
54
+ executable: str
55
+ required_help_flags: tuple[str, str]
56
+
57
+ def collect_sessions(self) -> list[dict[str, Any]]:
58
+ ...
59
+
60
+ def build_resume_command(self, session_id: str, dangerous: bool = False) -> list[str]:
61
+ ...
62
+
63
+ def compatibility(self) -> AgentCompatibility:
64
+ ...
65
+
66
+ def check_storage_contract(self, path: Path) -> ContractReport:
67
+ ...
68
+
69
+
70
+ def classify_resumability(session: dict[str, Any]) -> ResumeStatus:
71
+ """Classify resume evidence without treating a summary as resume evidence."""
72
+ if session.get("record_kind") in {"bridge_session", "metadata_only"}:
73
+ return ResumeStatus.METADATA_ONLY
74
+ if session.get("has_conversation_content") is False:
75
+ return ResumeStatus.METADATA_ONLY
76
+ if session.get("has_rollout") is False:
77
+ return ResumeStatus.INVALID
78
+ if any(
79
+ session.get(field) is True
80
+ for field in ("has_conversation_content", "has_rollout", "has_session_record")
81
+ ):
82
+ return ResumeStatus.RESUMABLE
83
+ if session.get("record_kind") == "conversation":
84
+ return ResumeStatus.LIKELY_RESUMABLE
85
+ return ResumeStatus.INVALID
86
+
87
+
88
+ def filter_resume_candidates(
89
+ sessions: list[dict[str, Any]], include_unverified: bool = False
90
+ ) -> list[dict[str, Any]]:
91
+ """Return verified candidates by default, retaining original record objects."""
92
+ if include_unverified:
93
+ return list(sessions)
94
+ return [
95
+ session
96
+ for session in sessions
97
+ if classify_resumability(session)
98
+ in {ResumeStatus.RESUMABLE, ResumeStatus.LIKELY_RESUMABLE}
99
+ ]
100
+
101
+
102
+ def validate_help_contract(help_text: str, required_flags: tuple[str, ...]) -> bool:
103
+ """Return whether all required capability markers are present in help text."""
104
+ return all(flag in help_text for flag in required_flags)
105
+
106
+
107
+ def read_jsonl_lines(file: Path) -> list[dict[str, Any]]:
108
+ """Return valid JSON object records from a JSON Lines file."""
109
+ try:
110
+ raw = file.read_text(encoding="utf-8")
111
+ except OSError:
112
+ return []
113
+
114
+ records = []
115
+ for line in raw.splitlines():
116
+ if not line.strip():
117
+ continue
118
+ try:
119
+ record = json.loads(line)
120
+ except json.JSONDecodeError:
121
+ continue
122
+ if isinstance(record, dict):
123
+ records.append(record)
124
+ return records
125
+
126
+
127
+ def parse_timestamp(value: Any) -> Optional[datetime]:
128
+ if not value:
129
+ return None
130
+ if isinstance(value, (int, float)):
131
+ return datetime.fromtimestamp(value / 1000, tz=timezone.utc)
132
+ try:
133
+ text = str(value).replace("Z", "+00:00")
134
+ date = datetime.fromisoformat(text)
135
+ return date.replace(tzinfo=timezone.utc) if date.tzinfo is None else date
136
+ except (TypeError, ValueError):
137
+ return None
138
+
139
+
140
+ def file_mtime(file: Path) -> datetime:
141
+ return datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc)
@@ -0,0 +1,113 @@
1
+ """Claude Code session discovery and resume integration."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .base import (
7
+ AgentAdapter,
8
+ AgentCompatibility,
9
+ ContractReport,
10
+ classify_resumability,
11
+ file_mtime,
12
+ parse_timestamp,
13
+ read_jsonl_lines,
14
+ )
15
+
16
+
17
+ HOME = Path.home()
18
+ CLAUDE_PROJECTS_DIR = HOME / ".claude" / "projects"
19
+
20
+
21
+ def message_text(content: Any) -> str:
22
+ if isinstance(content, str):
23
+ return content.strip()
24
+ if not isinstance(content, list):
25
+ return ""
26
+ parts = []
27
+ for item in content:
28
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
29
+ parts.append(item["text"])
30
+ return " ".join(part.strip() for part in parts if part.strip()).strip()
31
+
32
+
33
+ class ClaudeAdapter:
34
+ name = "claude"
35
+ executable = "claude"
36
+ required_help_flags = ("--resume", "--dangerously-skip-permissions")
37
+
38
+ def check_storage_contract(self, path: Path) -> ContractReport:
39
+ records = read_jsonl_lines(path)
40
+ if not records:
41
+ return ContractReport(self.name, False, ("jsonl",))
42
+ missing = () if all("type" in record for record in records) else ("type",)
43
+ return ContractReport(self.name, not missing, missing)
44
+
45
+ def collect_sessions(self) -> list[dict[str, Any]]:
46
+ sessions = []
47
+ if not CLAUDE_PROJECTS_DIR.is_dir():
48
+ return sessions
49
+
50
+ for project_dir in CLAUDE_PROJECTS_DIR.iterdir():
51
+ if not project_dir.is_dir():
52
+ continue
53
+ for file in project_dir.glob("*.jsonl"):
54
+ lines = read_jsonl_lines(file)
55
+ if not lines:
56
+ continue
57
+
58
+ cwd = summary = None
59
+ user_summary = None
60
+ last_timestamp = None
61
+ has_conversation_content = False
62
+ has_bridge_record = False
63
+ for entry in lines:
64
+ has_bridge_record = has_bridge_record or entry.get("type") == "bridge-session"
65
+ cwd = cwd or entry.get("cwd")
66
+ message = entry.get("message") or {}
67
+ content = message.get("content") if isinstance(message, dict) else None
68
+ text = message_text(content)
69
+ if entry.get("type") in {"user", "assistant"} and text:
70
+ has_conversation_content = True
71
+ if not user_summary and entry.get("type") == "user" and text:
72
+ user_summary = text
73
+ if entry.get("type") == "summary" and isinstance(entry.get("summary"), str):
74
+ summary = entry["summary"].strip() or summary
75
+ last_timestamp = entry.get("timestamp") or last_timestamp
76
+
77
+ summary = summary or user_summary
78
+
79
+ fallback_cwd = "/" + project_dir.name.lstrip("-").replace("-", "/")
80
+ session = {
81
+ "tool": self.name,
82
+ "id": file.stem,
83
+ "cwd": cwd or fallback_cwd,
84
+ "summary": summary or "(no summary available)",
85
+ "last_active": parse_timestamp(last_timestamp) or file_mtime(file),
86
+ "record_kind": (
87
+ "conversation"
88
+ if has_conversation_content
89
+ else "bridge_session"
90
+ if has_bridge_record
91
+ else "metadata_only"
92
+ ),
93
+ "has_conversation_content": has_conversation_content,
94
+ }
95
+ session["resume_status"] = classify_resumability(session).value
96
+ sessions.append(session)
97
+ return sessions
98
+
99
+ def build_resume_command(self, session_id: str, dangerous: bool = False) -> list[str]:
100
+ command = ["claude"]
101
+ if dangerous:
102
+ command.append("--dangerously-skip-permissions")
103
+ return command + ["--resume", session_id]
104
+
105
+ def compatibility(self) -> AgentCompatibility:
106
+ return AgentCompatibility(self.name, "2.1.263", "2.1.263", "2.1.263")
107
+
108
+
109
+ ADAPTER: AgentAdapter = ClaudeAdapter()
110
+
111
+
112
+ def collect_sessions() -> list[dict[str, Any]]:
113
+ return ADAPTER.collect_sessions()
@@ -0,0 +1,152 @@
1
+ """Codex session discovery and resume integration."""
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from .base import (
9
+ AgentAdapter,
10
+ AgentCompatibility,
11
+ ContractReport,
12
+ classify_resumability,
13
+ parse_timestamp,
14
+ read_jsonl_lines,
15
+ )
16
+
17
+
18
+ HOME = Path.home()
19
+ CODEX_SESSIONS_DIR = HOME / ".codex" / "sessions"
20
+ CODEX_INDEX_FILE = HOME / ".codex" / "session_index.jsonl"
21
+
22
+
23
+ def find_codex_rollout_file(session_id: str) -> Optional[Path]:
24
+ if not CODEX_SESSIONS_DIR.is_dir():
25
+ return None
26
+ for path in CODEX_SESSIONS_DIR.rglob("*"):
27
+ if path.is_file() and session_id in path.name:
28
+ return path
29
+ return None
30
+
31
+
32
+ def find_codex_state_db() -> Optional[Path]:
33
+ state_dbs = list((HOME / ".codex").glob("state_*.sqlite"))
34
+ return max(state_dbs, key=lambda path: path.stat().st_mtime, default=None)
35
+
36
+
37
+ class CodexAdapter:
38
+ name = "codex"
39
+ executable = "codex"
40
+ required_help_flags = ("resume", "--dangerously-bypass-approvals-and-sandbox")
41
+
42
+ def check_storage_contract(self, path: Path) -> ContractReport:
43
+ required = {
44
+ "id",
45
+ "cwd",
46
+ "title",
47
+ "first_user_message",
48
+ "preview",
49
+ "updated_at_ms",
50
+ "updated_at",
51
+ "archived",
52
+ }
53
+ try:
54
+ connection = sqlite3.connect(f"file:{path.resolve()}?mode=ro", uri=True)
55
+ try:
56
+ rows = connection.execute("PRAGMA table_info(threads)").fetchall()
57
+ finally:
58
+ connection.close()
59
+ except (OSError, sqlite3.Error):
60
+ return ContractReport(self.name, False, ("threads",))
61
+ if not rows:
62
+ return ContractReport(self.name, False, ("threads",))
63
+ columns = {row[1] for row in rows}
64
+ missing = tuple(f"threads.{column}" for column in sorted(required - columns))
65
+ return ContractReport(self.name, not missing, missing)
66
+
67
+ def collect_state_sessions(self) -> list[dict[str, Any]]:
68
+ state_db = find_codex_state_db()
69
+ if not state_db:
70
+ return []
71
+
72
+ try:
73
+ connection = sqlite3.connect(f"{state_db.resolve().as_uri()}?mode=ro", uri=True)
74
+ try:
75
+ rows = connection.execute(
76
+ """
77
+ SELECT id, cwd, title, first_user_message, preview, updated_at_ms, updated_at
78
+ FROM threads
79
+ WHERE archived = 0 AND preview <> ''
80
+ """
81
+ ).fetchall()
82
+ finally:
83
+ connection.close()
84
+ except (OSError, sqlite3.Error):
85
+ return []
86
+
87
+ sessions = []
88
+ for session_id, cwd, title, first_message, preview, updated_at_ms, updated_at in rows:
89
+ session = {
90
+ "tool": self.name,
91
+ "id": str(session_id),
92
+ "cwd": cwd or "(unknown)",
93
+ "summary": title or first_message or preview or "(no summary available)",
94
+ "last_active": parse_timestamp(updated_at_ms)
95
+ or parse_timestamp(updated_at)
96
+ or datetime.fromtimestamp(0, tz=timezone.utc),
97
+ "record_kind": "conversation",
98
+ "has_rollout": find_codex_rollout_file(str(session_id)) is not None,
99
+ }
100
+ session["resume_status"] = classify_resumability(session).value
101
+ sessions.append(session)
102
+ return sessions
103
+
104
+ def collect_sessions(self) -> list[dict[str, Any]]:
105
+ sessions = self.collect_state_sessions()
106
+ known_ids = {session["id"] for session in sessions}
107
+
108
+ for entry in read_jsonl_lines(CODEX_INDEX_FILE):
109
+ session_id = entry.get("id")
110
+ if not session_id or str(session_id) in known_ids:
111
+ continue
112
+ cwd = None
113
+ rollout_file = find_codex_rollout_file(str(session_id))
114
+ if rollout_file:
115
+ meta_line = next(
116
+ (line for line in read_jsonl_lines(rollout_file) if line.get("type") == "session_meta"),
117
+ None,
118
+ )
119
+ payload = meta_line.get("payload", {}) if meta_line else {}
120
+ if isinstance(payload, dict):
121
+ cwd = payload.get("cwd")
122
+
123
+ sessions.append(
124
+ {
125
+ "tool": self.name,
126
+ "id": str(session_id),
127
+ "cwd": cwd or "(unknown)",
128
+ "summary": entry.get("thread_name") or "(no summary available)",
129
+ "last_active": parse_timestamp(entry.get("updated_at"))
130
+ or datetime.fromtimestamp(0, tz=timezone.utc),
131
+ "record_kind": "conversation",
132
+ "has_rollout": rollout_file is not None,
133
+ }
134
+ )
135
+ sessions[-1]["resume_status"] = classify_resumability(sessions[-1]).value
136
+ return sessions
137
+
138
+ def build_resume_command(self, session_id: str, dangerous: bool = False) -> list[str]:
139
+ command = ["codex"]
140
+ if dangerous:
141
+ command.append("--dangerously-bypass-approvals-and-sandbox")
142
+ return command + ["resume", session_id]
143
+
144
+ def compatibility(self) -> AgentCompatibility:
145
+ return AgentCompatibility(self.name, "0.153.4", "0.153.4", "0.153.4")
146
+
147
+
148
+ ADAPTER: AgentAdapter = CodexAdapter()
149
+
150
+
151
+ def collect_sessions() -> list[dict[str, Any]]:
152
+ return ADAPTER.collect_sessions()
@@ -0,0 +1,97 @@
1
+ """Copilot session discovery and resume integration."""
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .base import AgentAdapter, AgentCompatibility, ContractReport, classify_resumability, parse_timestamp
9
+
10
+
11
+ HOME = Path.home()
12
+ COPILOT_DB_FILE = HOME / ".copilot" / "session-store.db"
13
+
14
+
15
+ class CopilotAdapter:
16
+ name = "copilot"
17
+ executable = "copilot"
18
+ required_help_flags = ("--resume", "--allow-all")
19
+
20
+ def check_storage_contract(self, path: Path) -> ContractReport:
21
+ required = {
22
+ "sessions": {"id", "cwd", "summary", "updated_at"},
23
+ "turns": {"session_id", "user_message", "turn_index"},
24
+ }
25
+ try:
26
+ connection = sqlite3.connect(f"file:{path.resolve()}?mode=ro", uri=True)
27
+ try:
28
+ missing = []
29
+ for table, columns in required.items():
30
+ rows = connection.execute(f"PRAGMA table_info({table})").fetchall()
31
+ if not rows:
32
+ missing.append(table)
33
+ continue
34
+ available = {row[1] for row in rows}
35
+ missing.extend(f"{table}.{column}" for column in sorted(columns - available))
36
+ finally:
37
+ connection.close()
38
+ except (OSError, sqlite3.Error):
39
+ return ContractReport(self.name, False, ("sessions", "turns"))
40
+ missing_tuple = tuple(missing)
41
+ return ContractReport(self.name, not missing_tuple, missing_tuple)
42
+
43
+ def collect_sessions(self) -> list[dict[str, Any]]:
44
+ if not COPILOT_DB_FILE.is_file():
45
+ return []
46
+
47
+ try:
48
+ connection = sqlite3.connect(f"file:{COPILOT_DB_FILE}?mode=ro", uri=True)
49
+ try:
50
+ sessions_rows = connection.execute(
51
+ "SELECT id, cwd, summary, updated_at FROM sessions"
52
+ ).fetchall()
53
+ first_messages = dict(
54
+ connection.execute(
55
+ """
56
+ SELECT session_id, user_message FROM turns
57
+ WHERE turn_index = 0
58
+ """
59
+ ).fetchall()
60
+ )
61
+ finally:
62
+ connection.close()
63
+ except (OSError, sqlite3.Error):
64
+ return []
65
+
66
+ sessions = []
67
+ for session_id, cwd, summary, updated_at in sessions_rows:
68
+ first_message = first_messages.get(session_id)
69
+ session = {
70
+ "tool": self.name,
71
+ "id": str(session_id),
72
+ "cwd": cwd or "(unknown)",
73
+ "summary": first_message or summary or "(no summary available)",
74
+ "last_active": parse_timestamp(updated_at)
75
+ or datetime.fromtimestamp(0, tz=timezone.utc),
76
+ "record_kind": "conversation",
77
+ "has_session_record": True,
78
+ }
79
+ session["resume_status"] = classify_resumability(session).value
80
+ sessions.append(session)
81
+ return sessions
82
+
83
+ def build_resume_command(self, session_id: str, dangerous: bool = False) -> list[str]:
84
+ command = ["copilot"]
85
+ if dangerous:
86
+ command.append("--allow-all")
87
+ return command + [f"--resume={session_id}"]
88
+
89
+ def compatibility(self) -> AgentCompatibility:
90
+ return AgentCompatibility(self.name, "1.0.82", "1.0.82", "1.0.82")
91
+
92
+
93
+ ADAPTER: AgentAdapter = CopilotAdapter()
94
+
95
+
96
+ def collect_sessions() -> list[dict[str, Any]]:
97
+ return ADAPTER.collect_sessions()
@@ -0,0 +1,26 @@
1
+ """Registry of supported service adapters."""
2
+
3
+ from .antigravity import ADAPTER as ANTIGRAVITY_ADAPTER
4
+ from .base import AgentAdapter
5
+ from .claude import ADAPTER as CLAUDE_ADAPTER
6
+ from .codex import ADAPTER as CODEX_ADAPTER
7
+ from .copilot import ADAPTER as COPILOT_ADAPTER
8
+
9
+
10
+ _ADAPTERS = {
11
+ adapter.name: adapter
12
+ for adapter in (
13
+ CLAUDE_ADAPTER,
14
+ CODEX_ADAPTER,
15
+ ANTIGRAVITY_ADAPTER,
16
+ COPILOT_ADAPTER,
17
+ )
18
+ }
19
+
20
+
21
+ def get_adapters() -> list[AgentAdapter]:
22
+ return list(_ADAPTERS.values())
23
+
24
+
25
+ def get_adapter(name: str) -> AgentAdapter:
26
+ return _ADAPTERS[name]
session_compass/cli.py ADDED
@@ -0,0 +1,139 @@
1
+ """List and resume Claude Code, Codex, Antigravity, and Copilot CLI sessions."""
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .agents.base import ResumeStatus, classify_resumability, filter_resume_candidates
14
+ from .agents.registry import get_adapter, get_adapters
15
+
16
+
17
+ HOME = Path.home()
18
+
19
+
20
+ def truncate(value: Any, length: int) -> str:
21
+ if not value:
22
+ return ""
23
+ clean = re.sub(r"\s+", " ", str(value)).strip()
24
+ return clean[: length - 1] + "…" if len(clean) > length else clean
25
+
26
+
27
+ def format_relative_time(date: datetime) -> str:
28
+ diff_minutes = round((datetime.now(timezone.utc) - date).total_seconds() / 60)
29
+ if diff_minutes < 1:
30
+ return "just now"
31
+ if diff_minutes < 60:
32
+ return f"{diff_minutes}m ago"
33
+ diff_hours = round(diff_minutes / 60)
34
+ if diff_hours < 24:
35
+ return f"{diff_hours}h ago"
36
+ diff_days = round(diff_hours / 24)
37
+ return f"{diff_days}d ago" if diff_days < 30 else date.date().isoformat()
38
+
39
+
40
+ def shorten_home(path: str) -> str:
41
+ home = str(HOME)
42
+ return "~" + path[len(home) :] if path.startswith(home) else path
43
+
44
+
45
+ def collect_claude_sessions() -> list[dict[str, Any]]:
46
+ return get_adapter("claude").collect_sessions()
47
+
48
+
49
+ def collect_codex_sessions() -> list[dict[str, Any]]:
50
+ return get_adapter("codex").collect_sessions()
51
+
52
+
53
+ def collect_antigravity_sessions() -> list[dict[str, Any]]:
54
+ return get_adapter("agy").collect_sessions()
55
+
56
+
57
+ def collect_copilot_sessions() -> list[dict[str, Any]]:
58
+ return get_adapter("copilot").collect_sessions()
59
+
60
+
61
+ def resume_session(session: dict[str, Any], dangerous: bool = False) -> None:
62
+ cwd = session["cwd"] if Path(session["cwd"]).exists() else os.getcwd()
63
+ adapter = get_adapter(session["tool"])
64
+ command_args = adapter.build_resume_command(session["id"], dangerous=dangerous)
65
+ print(f"\n> cd {shorten_home(cwd)} && {' '.join(command_args)}\n")
66
+ if not shutil.which(command_args[0]):
67
+ print(f"Command not found: {command_args[0]}", file=sys.stderr)
68
+ return
69
+ subprocess.run(command_args, cwd=cwd, check=False)
70
+
71
+
72
+ def main() -> None:
73
+ parser = argparse.ArgumentParser(description=__doc__)
74
+ filters = parser.add_mutually_exclusive_group()
75
+ filters.add_argument("--claude", action="store_const", const="claude", dest="tool")
76
+ filters.add_argument("--codex", action="store_const", const="codex", dest="tool")
77
+ filters.add_argument("--agy", action="store_const", const="agy", dest="tool")
78
+ filters.add_argument("--copilot", action="store_const", const="copilot", dest="tool")
79
+ parser.add_argument(
80
+ "--include-unverified",
81
+ action="store_true",
82
+ help="Include sessions without verified resume evidence",
83
+ )
84
+ parser.add_argument(
85
+ "-d",
86
+ "--dangerously-skip-permissions",
87
+ action="store_true",
88
+ help="Use each agent's native permission-bypass option when resuming",
89
+ )
90
+ args = parser.parse_args()
91
+
92
+ sessions = [session for adapter in get_adapters() for session in adapter.collect_sessions()]
93
+ if args.tool:
94
+ sessions = [session for session in sessions if session["tool"] == args.tool]
95
+ visible_sessions = filter_resume_candidates(sessions, include_unverified=args.include_unverified)
96
+ hidden_count = len(sessions) - len(visible_sessions)
97
+ sessions = visible_sessions
98
+ if hidden_count and not args.include_unverified:
99
+ print(f"{hidden_count} unverified sessions hidden. Use --include-unverified to inspect them.")
100
+ sessions.sort(key=lambda session: session["last_active"])
101
+ if not sessions:
102
+ print("No sessions found.")
103
+ return
104
+
105
+ index_width = len(str(len(sessions)))
106
+ print()
107
+ for index, session in enumerate(sessions, start=1):
108
+ project = truncate(shorten_home(session["cwd"]), 32)
109
+ print(
110
+ f"{index:>{index_width}}) [{session['tool']:<7}] {format_relative_time(session['last_active']):<9} "
111
+ f"{project:<32} {truncate(session['summary'], 70)}"
112
+ )
113
+ if args.include_unverified:
114
+ status = classify_resumability(session)
115
+ if status not in {ResumeStatus.RESUMABLE, ResumeStatus.LIKELY_RESUMABLE}:
116
+ print(f"{' ' * (index_width + 2)}status: {status.value}")
117
+ print(f"{' ' * (index_width + 2)}id: {session['id']}")
118
+ print()
119
+
120
+ answer = input("Resume which session? (number, or q to quit): ").strip().lower()
121
+ if not answer or answer == "q":
122
+ return
123
+ try:
124
+ choice = int(answer)
125
+ except ValueError:
126
+ choice = 0
127
+ if not 1 <= choice <= len(sessions):
128
+ print("Invalid selection.")
129
+ return
130
+ selected = sessions[choice - 1]
131
+ status = classify_resumability(selected)
132
+ if status not in {ResumeStatus.RESUMABLE, ResumeStatus.LIKELY_RESUMABLE}:
133
+ print(f"Session is not resumable ({status.value}).")
134
+ return
135
+ resume_session(selected, dangerous=args.dangerously_skip_permissions)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.5
2
+ Name: session-compass
3
+ Version: 0.1.0
4
+ Summary: Discover and resume local sessions from Claude Code, Codex, Antigravity, and Copilot CLI
5
+ Author-email: Yeonchan Ahn <ahnyeonchan@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: antigravity,claude-code,cli,codex,coding-agents,copilot,resume,sessions
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Session Compass
18
+
19
+ [![PyPI](https://img.shields.io/pypi/v/session-compass)](https://pypi.org/project/session-compass/)
20
+
21
+ Session Compass is a local-first CLI for discovering, identifying, and resuming sessions created by Claude Code, Codex, Antigravity CLI, and Copilot CLI.
22
+
23
+ This project started as a fork of [cli-sessions](https://github.com/pavbyte/cli-sessions). It keeps the upstream project's practical local-session workflow while adding provider-specific metadata, resumability checks, and compatibility monitoring for independently updated agent CLIs.
24
+
25
+ ## Why Session Compass?
26
+
27
+ Coding agents store useful session data locally, but each agent exposes that history differently. Session Compass gives you one starting point for answering:
28
+
29
+ - Which agent created this session?
30
+ - Which workspace was it using?
31
+ - What was the session about?
32
+ - When was it last active?
33
+ - Can it be resumed safely?
34
+
35
+ It is designed for local development machines and remote Linux servers accessed through SSH. It does not require a daemon, a central database, an account, or a web service.
36
+
37
+ ## Install
38
+
39
+ Using `pipx` is recommended for command-line tools because it keeps Session Compass isolated from other Python applications:
40
+
41
+ ```bash
42
+ pipx install session-compass
43
+ ```
44
+
45
+ You can also install it with `pip`:
46
+
47
+ ```bash
48
+ pip install session-compass
49
+ ```
50
+
51
+ The repository includes an optional installer for machines where `pipx` is not already configured:
52
+
53
+ ```bash
54
+ curl -fsSL https://raw.githubusercontent.com/ycahn82/session-compass/main/install.sh | bash
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ scompass # List sessions from all supported agents
61
+ scompass --claude # Show Claude Code sessions
62
+ scompass --codex # Show Codex sessions
63
+ scompass --agy # Show Antigravity sessions
64
+ scompass --copilot # Show Copilot CLI sessions
65
+ ```
66
+
67
+ Choose a session number to resume it in its original working directory. Press `q` to exit without resuming.
68
+
69
+ Use `--include-unverified` when diagnosing records that do not have enough local evidence to be considered resumable:
70
+
71
+ ```bash
72
+ scompass --include-unverified
73
+ ```
74
+
75
+ ### Permission bypass
76
+
77
+ Use `-d` as the short alias for `--dangerously-skip-permissions` when resuming a session:
78
+
79
+ ```bash
80
+ scompass -d
81
+ scompass --claude -d
82
+ ```
83
+
84
+ Session Compass translates this shared option to each provider's native permission-bypass option. This can reduce or remove safety prompts from the provider, so use it only in environments where you understand the consequences.
85
+
86
+ ## Supported agents
87
+
88
+ Session Compass currently reads local session metadata from these locations:
89
+
90
+ ```text
91
+ Claude Code: ~/.claude/projects/*/*.jsonl
92
+ Codex: ~/.codex/state_*.sqlite or ~/.codex/session_index.jsonl
93
+ Antigravity: ~/.gemini/antigravity-cli/conversations/*.db and history.jsonl
94
+ Copilot CLI: ~/.copilot/session-store.db
95
+ ```
96
+
97
+ An agent that is not installed is skipped. Session databases are opened for metadata discovery only; Session Compass does not migrate, rewrite, or delete them.
98
+
99
+ The default list focuses on sessions with evidence that they can be resumed. Internal and metadata-only records are hidden from that list. Use `--include-unverified` to inspect diagnostic records without making them normal resume candidates.
100
+
101
+ ## Local-first and privacy
102
+
103
+ Session Compass reads files already written on your machine. It does not send session content anywhere and does not include telemetry. The current implementation does not use an LLM to generate summaries; it prefers deterministic titles, previews, and user-message metadata already present in local storage.
104
+
105
+ ## Compatibility policy
106
+
107
+ Agent CLIs can update independently, including their resume arguments and local storage schemas. Each Session Compass provider adapter owns its storage contract, metadata extraction, resumability evidence, and native resume command.
108
+
109
+ The project maintains separate compatibility floors for the provider CLI's resume command and its session-storage schema. GitHub Actions checks supported provider versions and help contracts weekly and on pull requests. When an upstream change breaks a contract, maintainers review the report and update the affected adapter; normal runtime execution does not probe provider help on every invocation.
110
+
111
+ ## Current capabilities
112
+
113
+ - Unified session listing across supported coding agents
114
+ - Provider and workspace metadata
115
+ - Deterministic title and summary extraction from local records
116
+ - Resumability filtering for internal, incomplete, or metadata-only records
117
+ - Native resume command mapping per provider
118
+ - Shared `-d`/`--dangerously-skip-permissions` option
119
+ - Read-only compatibility and storage-contract tests
120
+
121
+ ## Roadmap
122
+
123
+ The master plan is intentionally incremental. Planned work includes:
124
+
125
+ - Richer text search across agent, project, workspace, title, and branch metadata
126
+ - A stable machine-readable JSON output mode
127
+ - A read-only `doctor` diagnostic command
128
+ - Optional aggregation of session metadata from explicitly selected remote hosts
129
+ - Better provenance for workspace and Git context
130
+
131
+ These roadmap items are not required for the current interactive listing and resume workflow.
132
+
133
+ ## Fork maintenance
134
+
135
+ The upstream project is [pavbyte/cli-sessions](https://github.com/pavbyte/cli-sessions). Session Compass keeps the upstream remote separate and documents intentional differences in the repository's development plans.
136
+
137
+ The public package and command are intentionally separate from the upstream project:
138
+
139
+ ```text
140
+ Upstream cli-sessions: pip install cli-sessions -> sessions
141
+ Session Compass: pip install session-compass -> scompass
142
+ ```
143
+
144
+ Session Compass does not provide a `sessions` alias. This prevents a new installation from replacing or shadowing an existing upstream `cli-sessions` command.
145
+
146
+ ## Links
147
+
148
+ - Source: https://github.com/ycahn82/session-compass
149
+ - Upstream: https://github.com/pavbyte/cli-sessions
150
+ - PyPI: https://pypi.org/project/session-compass/
151
+
152
+ ## License
153
+
154
+ MIT
@@ -0,0 +1,14 @@
1
+ session_compass/__init__.py,sha256=85TZbuHBf2KCnIVdzkaxceAcZvV-6zwcTeOb2kysANM,121
2
+ session_compass/cli.py,sha256=LDOUtdEsKsTbqT445REG16mSqnXOC19mKfv8uWcBxMU,5093
3
+ session_compass/agents/__init__.py,sha256=9CSLCsWhbt-RdbSIzfSgIkjUZCnV870VxsVn__zRJMs,142
4
+ session_compass/agents/antigravity.py,sha256=BMGEJHiaxl1laSLb9p74B2wtIKtj_FVvZnwSTvXdxA0,6584
5
+ session_compass/agents/base.py,sha256=NA_lAYmv9dXbWIcpVuVSDlKUaAReB8cmLL99UHOd4ds,3997
6
+ session_compass/agents/claude.py,sha256=RKSsXH3e-4Zq7ztrd_uJ7ik8p-h89t7VSza_U7VkvAU,4291
7
+ session_compass/agents/codex.py,sha256=PcM2D27WUvTY8EJtbz-H4xjuB9QzZ7hSLgCrTuLN_K8,5507
8
+ session_compass/agents/copilot.py,sha256=r8yA_fnalmDHp-yU3sA91U1uz4d1ziaNUb5XNpC8G_Y,3619
9
+ session_compass/agents/registry.py,sha256=SB25joZBUR-QjoYoBnBDPZeG5R379FfNv2QWN-zEP1s,597
10
+ session_compass-0.1.0.dist-info/METADATA,sha256=RgYkkgnElVGXRnGA7vnffAyL7QlHUFc_Zmsyr_IRgmY,6512
11
+ session_compass-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ session_compass-0.1.0.dist-info/entry_points.txt,sha256=29oe_TPIHxSjTCHr6gqgntnR7g9g-n15fxNFfLLwkYY,54
13
+ session_compass-0.1.0.dist-info/licenses/LICENSE,sha256=Piexuh-nEfK7-gu8JzbN_DVuNDQPHHjNmFS2_zyEOzo,1066
14
+ session_compass-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scompass = session_compass.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 12signals
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.