fast-resume 1.12.8__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.
- fast_resume/__init__.py +5 -0
- fast_resume/adapters/__init__.py +25 -0
- fast_resume/adapters/base.py +263 -0
- fast_resume/adapters/claude.py +209 -0
- fast_resume/adapters/codex.py +216 -0
- fast_resume/adapters/copilot.py +176 -0
- fast_resume/adapters/copilot_vscode.py +326 -0
- fast_resume/adapters/crush.py +341 -0
- fast_resume/adapters/opencode.py +333 -0
- fast_resume/adapters/vibe.py +188 -0
- fast_resume/assets/claude.png +0 -0
- fast_resume/assets/codex.png +0 -0
- fast_resume/assets/copilot-cli.png +0 -0
- fast_resume/assets/copilot-vscode.png +0 -0
- fast_resume/assets/crush.png +0 -0
- fast_resume/assets/opencode.png +0 -0
- fast_resume/assets/vibe.png +0 -0
- fast_resume/cli.py +327 -0
- fast_resume/config.py +30 -0
- fast_resume/index.py +758 -0
- fast_resume/logging_config.py +57 -0
- fast_resume/query.py +264 -0
- fast_resume/search.py +281 -0
- fast_resume/tui/__init__.py +58 -0
- fast_resume/tui/app.py +629 -0
- fast_resume/tui/filter_bar.py +128 -0
- fast_resume/tui/modal.py +73 -0
- fast_resume/tui/preview.py +396 -0
- fast_resume/tui/query.py +86 -0
- fast_resume/tui/results_table.py +178 -0
- fast_resume/tui/search_input.py +117 -0
- fast_resume/tui/styles.py +302 -0
- fast_resume/tui/utils.py +160 -0
- fast_resume-1.12.8.dist-info/METADATA +545 -0
- fast_resume-1.12.8.dist-info/RECORD +38 -0
- fast_resume-1.12.8.dist-info/WHEEL +4 -0
- fast_resume-1.12.8.dist-info/entry_points.txt +3 -0
- fast_resume-1.12.8.dist-info/licenses/LICENSE +21 -0
fast_resume/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Agent adapters for different coding tools."""
|
|
2
|
+
|
|
3
|
+
from .base import AgentAdapter, ErrorCallback, ParseError, RawAdapterStats, Session
|
|
4
|
+
from .claude import ClaudeAdapter
|
|
5
|
+
from .codex import CodexAdapter
|
|
6
|
+
from .copilot import CopilotAdapter
|
|
7
|
+
from .copilot_vscode import CopilotVSCodeAdapter
|
|
8
|
+
from .crush import CrushAdapter
|
|
9
|
+
from .opencode import OpenCodeAdapter
|
|
10
|
+
from .vibe import VibeAdapter
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AgentAdapter",
|
|
14
|
+
"ErrorCallback",
|
|
15
|
+
"ParseError",
|
|
16
|
+
"RawAdapterStats",
|
|
17
|
+
"Session",
|
|
18
|
+
"ClaudeAdapter",
|
|
19
|
+
"CodexAdapter",
|
|
20
|
+
"CopilotAdapter",
|
|
21
|
+
"CopilotVSCodeAdapter",
|
|
22
|
+
"CrushAdapter",
|
|
23
|
+
"OpenCodeAdapter",
|
|
24
|
+
"VibeAdapter",
|
|
25
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Base protocol and abstract class for agent adapters."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# 1ms tolerance for mtime comparison due to datetime precision loss
|
|
14
|
+
MTIME_TOLERANCE = 0.001
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def truncate_title(text: str, max_length: int = 100, word_break: bool = True) -> str:
|
|
18
|
+
"""Truncate title text with optional word-break.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
text: The text to truncate
|
|
22
|
+
max_length: Maximum length before truncation (default 100)
|
|
23
|
+
word_break: If True, break at last word boundary; if False, hard truncate
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Truncated title with "..." suffix if text exceeded max_length
|
|
27
|
+
"""
|
|
28
|
+
text = text.strip()
|
|
29
|
+
if len(text) <= max_length:
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
truncated = text[:max_length]
|
|
33
|
+
if word_break:
|
|
34
|
+
# Break at last word boundary
|
|
35
|
+
truncated = truncated.rsplit(" ", 1)[0]
|
|
36
|
+
return truncated + "..."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Session:
|
|
41
|
+
"""Represents a coding agent session."""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
agent: str # "claude", "codex", "crush", "opencode", "vibe"
|
|
45
|
+
title: str
|
|
46
|
+
directory: str
|
|
47
|
+
timestamp: datetime
|
|
48
|
+
content: str # Full searchable content
|
|
49
|
+
message_count: int = 0 # Number of user + assistant messages
|
|
50
|
+
mtime: float = 0.0 # File modification time for incremental updates
|
|
51
|
+
yolo: bool = False # Session was started with auto-approve/skip-permissions
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RawAdapterStats:
|
|
56
|
+
"""Raw statistics from an adapter's data folder."""
|
|
57
|
+
|
|
58
|
+
agent: str
|
|
59
|
+
data_dir: str # Path to the data directory
|
|
60
|
+
available: bool # Whether the data directory exists
|
|
61
|
+
file_count: int # Number of session files
|
|
62
|
+
total_bytes: int # Total size in bytes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ParseError:
|
|
67
|
+
"""Represents a session parsing error."""
|
|
68
|
+
|
|
69
|
+
agent: str # Which adapter encountered the error
|
|
70
|
+
file_path: str # Path to the problematic file
|
|
71
|
+
error_type: str # e.g., "JSONDecodeError", "KeyError", "OSError"
|
|
72
|
+
message: str # Human-readable error message
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Type alias for error callback
|
|
76
|
+
ErrorCallback = Callable[["ParseError"], None] | None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AgentAdapter(Protocol):
|
|
80
|
+
"""Protocol for agent-specific session adapters."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
color: str
|
|
84
|
+
badge: str
|
|
85
|
+
|
|
86
|
+
def find_sessions(self) -> list[Session]:
|
|
87
|
+
"""Find all sessions for this agent."""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def find_sessions_incremental(
|
|
91
|
+
self,
|
|
92
|
+
known: dict[str, tuple[float, str]],
|
|
93
|
+
on_error: ErrorCallback = None,
|
|
94
|
+
) -> tuple[list[Session], list[str]]:
|
|
95
|
+
"""Find sessions incrementally, comparing against known sessions.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
known: Dict mapping session_id to (mtime, agent_name) tuple
|
|
99
|
+
on_error: Optional callback for parse errors
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Tuple of (new_or_modified sessions, deleted session IDs)
|
|
103
|
+
"""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
def get_resume_command(self, session: "Session", yolo: bool = False) -> list[str]:
|
|
107
|
+
"""Get the command to resume a session.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
session: The session to resume
|
|
111
|
+
yolo: If True, add auto-approve/skip-permissions flags
|
|
112
|
+
"""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
def is_available(self) -> bool:
|
|
116
|
+
"""Check if this agent's data directory exists."""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
def get_raw_stats(self) -> RawAdapterStats:
|
|
120
|
+
"""Get raw statistics from the adapter's data folder."""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def supports_yolo(self) -> bool:
|
|
125
|
+
"""Whether this adapter supports yolo mode in resume command."""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class BaseSessionAdapter(ABC):
|
|
130
|
+
"""Base class for file-based session adapters.
|
|
131
|
+
|
|
132
|
+
Provides a template method for find_sessions_incremental().
|
|
133
|
+
Used by Claude, Copilot, Codex, and Vibe adapters.
|
|
134
|
+
|
|
135
|
+
Subclasses implement:
|
|
136
|
+
- _scan_session_files(): Return dict of session_id -> (path, mtime)
|
|
137
|
+
- _parse_session_file(): Parse a single file into a Session
|
|
138
|
+
- find_sessions(): Find all sessions
|
|
139
|
+
- get_resume_command(): Command to resume a session
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
name: str
|
|
143
|
+
color: str
|
|
144
|
+
badge: str
|
|
145
|
+
_sessions_dir: Path
|
|
146
|
+
|
|
147
|
+
def is_available(self) -> bool:
|
|
148
|
+
"""Check if data directory exists."""
|
|
149
|
+
return self._sessions_dir.exists()
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def supports_yolo(self) -> bool:
|
|
153
|
+
"""Whether this adapter supports yolo mode in resume command.
|
|
154
|
+
|
|
155
|
+
Override in subclasses that support yolo flags.
|
|
156
|
+
"""
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
@abstractmethod
|
|
160
|
+
def _scan_session_files(self) -> dict[str, tuple[Path, float]]:
|
|
161
|
+
"""Scan session files and return current state.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dict mapping session_id to (file_path, mtime) tuple
|
|
165
|
+
"""
|
|
166
|
+
...
|
|
167
|
+
|
|
168
|
+
@abstractmethod
|
|
169
|
+
def _parse_session_file(
|
|
170
|
+
self, session_file: Path, on_error: ErrorCallback = None
|
|
171
|
+
) -> Session | None:
|
|
172
|
+
"""Parse a single session file.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
session_file: Path to the session file
|
|
176
|
+
on_error: Optional callback for parse errors
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Session object or None if parsing failed
|
|
180
|
+
"""
|
|
181
|
+
...
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
def find_sessions(self) -> list[Session]:
|
|
185
|
+
"""Find all sessions for this agent."""
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
@abstractmethod
|
|
189
|
+
def get_resume_command(self, session: Session, yolo: bool = False) -> list[str]:
|
|
190
|
+
"""Get command to resume a session."""
|
|
191
|
+
...
|
|
192
|
+
|
|
193
|
+
def find_sessions_incremental(
|
|
194
|
+
self,
|
|
195
|
+
known: dict[str, tuple[float, str]],
|
|
196
|
+
on_error: ErrorCallback = None,
|
|
197
|
+
) -> tuple[list[Session], list[str]]:
|
|
198
|
+
"""Find sessions incrementally using template method pattern.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
known: Dict mapping session_id to (mtime, agent_name) tuple
|
|
202
|
+
on_error: Optional callback for parse errors
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Tuple of (new_or_modified sessions, deleted session IDs)
|
|
206
|
+
"""
|
|
207
|
+
if not self.is_available():
|
|
208
|
+
# All known sessions from this agent are deleted
|
|
209
|
+
deleted_ids = [
|
|
210
|
+
sid for sid, (_, agent) in known.items() if agent == self.name
|
|
211
|
+
]
|
|
212
|
+
return [], deleted_ids
|
|
213
|
+
|
|
214
|
+
# Scan current session files
|
|
215
|
+
current_files = self._scan_session_files()
|
|
216
|
+
|
|
217
|
+
# Find new and modified sessions
|
|
218
|
+
new_or_modified = []
|
|
219
|
+
for session_id, (path, mtime) in current_files.items():
|
|
220
|
+
known_entry = known.get(session_id)
|
|
221
|
+
if known_entry is None or mtime > known_entry[0] + MTIME_TOLERANCE:
|
|
222
|
+
session = self._parse_session_file(path, on_error=on_error)
|
|
223
|
+
if session:
|
|
224
|
+
session.mtime = mtime
|
|
225
|
+
new_or_modified.append(session)
|
|
226
|
+
|
|
227
|
+
# Find deleted sessions (in known but not in current, for this agent only)
|
|
228
|
+
current_ids = set(current_files.keys())
|
|
229
|
+
deleted_ids = [
|
|
230
|
+
sid
|
|
231
|
+
for sid, (_, agent) in known.items()
|
|
232
|
+
if agent == self.name and sid not in current_ids
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
return new_or_modified, deleted_ids
|
|
236
|
+
|
|
237
|
+
def get_raw_stats(self) -> RawAdapterStats:
|
|
238
|
+
"""Get raw statistics from the adapter's data folder."""
|
|
239
|
+
if not self.is_available():
|
|
240
|
+
return RawAdapterStats(
|
|
241
|
+
agent=self.name,
|
|
242
|
+
data_dir=str(self._sessions_dir),
|
|
243
|
+
available=False,
|
|
244
|
+
file_count=0,
|
|
245
|
+
total_bytes=0,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Use _scan_session_files to get file info
|
|
249
|
+
files = self._scan_session_files()
|
|
250
|
+
total_bytes = 0
|
|
251
|
+
for path, _ in files.values():
|
|
252
|
+
try:
|
|
253
|
+
total_bytes += path.stat().st_size
|
|
254
|
+
except OSError:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
return RawAdapterStats(
|
|
258
|
+
agent=self.name,
|
|
259
|
+
data_dir=str(self._sessions_dir),
|
|
260
|
+
available=True,
|
|
261
|
+
file_count=len(files),
|
|
262
|
+
total_bytes=total_bytes,
|
|
263
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Claude Code session adapter."""
|
|
2
|
+
|
|
3
|
+
import orjson
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..config import AGENTS, CLAUDE_DIR
|
|
8
|
+
from ..logging_config import log_parse_error
|
|
9
|
+
from .base import BaseSessionAdapter, ErrorCallback, ParseError, Session, truncate_title
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClaudeAdapter(BaseSessionAdapter):
|
|
13
|
+
"""Adapter for Claude Code sessions."""
|
|
14
|
+
|
|
15
|
+
name = "claude"
|
|
16
|
+
color = AGENTS["claude"]["color"]
|
|
17
|
+
badge = AGENTS["claude"]["badge"]
|
|
18
|
+
supports_yolo = True
|
|
19
|
+
|
|
20
|
+
def __init__(self, sessions_dir: Path | None = None) -> None:
|
|
21
|
+
self._sessions_dir = sessions_dir if sessions_dir is not None else CLAUDE_DIR
|
|
22
|
+
|
|
23
|
+
def find_sessions(self) -> list[Session]:
|
|
24
|
+
"""Find all Claude Code sessions."""
|
|
25
|
+
if not self.is_available():
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
sessions = []
|
|
29
|
+
for project_dir in self._sessions_dir.iterdir():
|
|
30
|
+
if not project_dir.is_dir():
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
for session_file in project_dir.glob("*.jsonl"):
|
|
34
|
+
# Skip agent subprocesses
|
|
35
|
+
if session_file.name.startswith("agent-"):
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
session = self._parse_session_file(session_file)
|
|
39
|
+
if session:
|
|
40
|
+
sessions.append(session)
|
|
41
|
+
|
|
42
|
+
return sessions
|
|
43
|
+
|
|
44
|
+
def _parse_session_file(
|
|
45
|
+
self, session_file: Path, on_error: ErrorCallback = None
|
|
46
|
+
) -> Session | None:
|
|
47
|
+
"""Parse a Claude Code session file."""
|
|
48
|
+
try:
|
|
49
|
+
first_user_message = ""
|
|
50
|
+
directory = ""
|
|
51
|
+
timestamp = datetime.fromtimestamp(session_file.stat().st_mtime)
|
|
52
|
+
messages: list[str] = []
|
|
53
|
+
# Count conversation turns (user + assistant, not tool results)
|
|
54
|
+
turn_count = 0
|
|
55
|
+
|
|
56
|
+
with open(session_file, "rb") as f:
|
|
57
|
+
for line in f:
|
|
58
|
+
if not line.strip():
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
data = orjson.loads(line)
|
|
62
|
+
except orjson.JSONDecodeError:
|
|
63
|
+
# Skip malformed lines within the file
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
msg_type = data.get("type", "")
|
|
67
|
+
|
|
68
|
+
# Get directory from user message
|
|
69
|
+
if msg_type == "user" and not directory:
|
|
70
|
+
directory = data.get("cwd", "")
|
|
71
|
+
|
|
72
|
+
# Process user messages
|
|
73
|
+
if msg_type == "user":
|
|
74
|
+
msg = data.get("message", {})
|
|
75
|
+
content = msg.get("content", "")
|
|
76
|
+
|
|
77
|
+
# Check if this is a real human input or automatic tool result
|
|
78
|
+
is_human_input = False
|
|
79
|
+
if isinstance(content, str):
|
|
80
|
+
is_human_input = True
|
|
81
|
+
if not data.get("isMeta") and not content.startswith(
|
|
82
|
+
("<command", "<local-command")
|
|
83
|
+
):
|
|
84
|
+
messages.append(f"» {content}")
|
|
85
|
+
if not first_user_message and len(content) > 10:
|
|
86
|
+
first_user_message = content
|
|
87
|
+
elif isinstance(content, list):
|
|
88
|
+
# Check first part - if it's text (not tool_result), it's human
|
|
89
|
+
first_part = content[0] if content else {}
|
|
90
|
+
if isinstance(first_part, dict):
|
|
91
|
+
part_type = first_part.get("type", "")
|
|
92
|
+
if part_type == "text":
|
|
93
|
+
is_human_input = True
|
|
94
|
+
# tool_result means automatic response, not human input
|
|
95
|
+
|
|
96
|
+
for part in content:
|
|
97
|
+
if (
|
|
98
|
+
isinstance(part, dict)
|
|
99
|
+
and part.get("type") == "text"
|
|
100
|
+
):
|
|
101
|
+
text = part.get("text", "")
|
|
102
|
+
messages.append(f"» {text}")
|
|
103
|
+
if not first_user_message:
|
|
104
|
+
first_user_message = text
|
|
105
|
+
elif isinstance(part, str):
|
|
106
|
+
messages.append(f"» {part}")
|
|
107
|
+
|
|
108
|
+
if is_human_input:
|
|
109
|
+
turn_count += 1
|
|
110
|
+
|
|
111
|
+
# Extract assistant content
|
|
112
|
+
if msg_type == "assistant":
|
|
113
|
+
msg = data.get("message", {})
|
|
114
|
+
content = msg.get("content", "")
|
|
115
|
+
has_text = False
|
|
116
|
+
if isinstance(content, str) and content:
|
|
117
|
+
messages.append(f" {content}")
|
|
118
|
+
has_text = True
|
|
119
|
+
elif isinstance(content, list):
|
|
120
|
+
for part in content:
|
|
121
|
+
if (
|
|
122
|
+
isinstance(part, dict)
|
|
123
|
+
and part.get("type") == "text"
|
|
124
|
+
):
|
|
125
|
+
text = part.get("text", "")
|
|
126
|
+
if text:
|
|
127
|
+
messages.append(f" {text}")
|
|
128
|
+
has_text = True
|
|
129
|
+
elif isinstance(part, str):
|
|
130
|
+
messages.append(f" {part}")
|
|
131
|
+
has_text = True
|
|
132
|
+
if has_text:
|
|
133
|
+
turn_count += 1
|
|
134
|
+
|
|
135
|
+
# Skip sessions with no actual user message
|
|
136
|
+
if not first_user_message:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# Always use first user message as title (matches Claude Code's Resume Session UI)
|
|
140
|
+
# The summary field is not a good title - it's often stale after session resume
|
|
141
|
+
title = truncate_title(first_user_message)
|
|
142
|
+
|
|
143
|
+
# Skip sessions with no actual conversation content
|
|
144
|
+
if not messages:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
full_content = "\n\n".join(messages)
|
|
148
|
+
|
|
149
|
+
return Session(
|
|
150
|
+
id=session_file.stem,
|
|
151
|
+
agent=self.name,
|
|
152
|
+
title=title,
|
|
153
|
+
directory=directory,
|
|
154
|
+
timestamp=timestamp,
|
|
155
|
+
content=full_content,
|
|
156
|
+
message_count=turn_count,
|
|
157
|
+
)
|
|
158
|
+
except OSError as e:
|
|
159
|
+
error = ParseError(
|
|
160
|
+
agent=self.name,
|
|
161
|
+
file_path=str(session_file),
|
|
162
|
+
error_type="OSError",
|
|
163
|
+
message=str(e),
|
|
164
|
+
)
|
|
165
|
+
log_parse_error(
|
|
166
|
+
error.agent, error.file_path, error.error_type, error.message
|
|
167
|
+
)
|
|
168
|
+
if on_error:
|
|
169
|
+
on_error(error)
|
|
170
|
+
return None
|
|
171
|
+
except (KeyError, TypeError, AttributeError) as e:
|
|
172
|
+
error = ParseError(
|
|
173
|
+
agent=self.name,
|
|
174
|
+
file_path=str(session_file),
|
|
175
|
+
error_type=type(e).__name__,
|
|
176
|
+
message=str(e),
|
|
177
|
+
)
|
|
178
|
+
log_parse_error(
|
|
179
|
+
error.agent, error.file_path, error.error_type, error.message
|
|
180
|
+
)
|
|
181
|
+
if on_error:
|
|
182
|
+
on_error(error)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def _scan_session_files(self) -> dict[str, tuple[Path, float]]:
|
|
186
|
+
"""Scan all Claude Code session files."""
|
|
187
|
+
current_files: dict[str, tuple[Path, float]] = {}
|
|
188
|
+
|
|
189
|
+
for project_dir in self._sessions_dir.iterdir():
|
|
190
|
+
if not project_dir.is_dir():
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
for session_file in project_dir.glob("*.jsonl"):
|
|
194
|
+
if session_file.name.startswith("agent-"):
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
session_id = session_file.stem
|
|
198
|
+
mtime = session_file.stat().st_mtime
|
|
199
|
+
current_files[session_id] = (session_file, mtime)
|
|
200
|
+
|
|
201
|
+
return current_files
|
|
202
|
+
|
|
203
|
+
def get_resume_command(self, session: Session, yolo: bool = False) -> list[str]:
|
|
204
|
+
"""Get command to resume a Claude Code session."""
|
|
205
|
+
cmd = ["claude"]
|
|
206
|
+
if yolo:
|
|
207
|
+
cmd.append("--dangerously-skip-permissions")
|
|
208
|
+
cmd.extend(["--resume", session.id])
|
|
209
|
+
return cmd
|