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.
- session_compass/__init__.py +3 -0
- session_compass/agents/__init__.py +5 -0
- session_compass/agents/antigravity.py +173 -0
- session_compass/agents/base.py +141 -0
- session_compass/agents/claude.py +113 -0
- session_compass/agents/codex.py +152 -0
- session_compass/agents/copilot.py +97 -0
- session_compass/agents/registry.py +26 -0
- session_compass/cli.py +139 -0
- session_compass-0.1.0.dist-info/METADATA +154 -0
- session_compass-0.1.0.dist-info/RECORD +14 -0
- session_compass-0.1.0.dist-info/WHEEL +4 -0
- session_compass-0.1.0.dist-info/entry_points.txt +2 -0
- session_compass-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](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,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.
|