overcode 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.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- overcode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read Claude Code's history and session files for interaction/token counting.
|
|
3
|
+
|
|
4
|
+
Claude Code stores data in:
|
|
5
|
+
- ~/.claude/history.jsonl - interaction history (prompts sent)
|
|
6
|
+
- ~/.claude/projects/{encoded-path}/{sessionId}.jsonl - full conversation with token usage
|
|
7
|
+
|
|
8
|
+
Each assistant message in session files has usage data:
|
|
9
|
+
{
|
|
10
|
+
"usage": {
|
|
11
|
+
"input_tokens": 1003,
|
|
12
|
+
"cache_creation_input_tokens": 2884,
|
|
13
|
+
"cache_read_input_tokens": 25944,
|
|
14
|
+
"output_tokens": 278
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .session_manager import Session
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
CLAUDE_HISTORY_PATH = Path.home() / ".claude" / "history.jsonl"
|
|
30
|
+
CLAUDE_PROJECTS_PATH = Path.home() / ".claude" / "projects"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ClaudeSessionStats:
|
|
35
|
+
"""Statistics for a Claude Code session."""
|
|
36
|
+
interaction_count: int
|
|
37
|
+
input_tokens: int
|
|
38
|
+
output_tokens: int
|
|
39
|
+
cache_creation_tokens: int
|
|
40
|
+
cache_read_tokens: int
|
|
41
|
+
work_times: List[float] # seconds per work cycle (prompt to next prompt)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def total_tokens(self) -> int:
|
|
45
|
+
"""Total tokens (input + output, not counting cache)."""
|
|
46
|
+
return self.input_tokens + self.output_tokens
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def total_tokens_with_cache(self) -> int:
|
|
50
|
+
"""Total tokens including cache operations."""
|
|
51
|
+
return (self.input_tokens + self.output_tokens +
|
|
52
|
+
self.cache_creation_tokens + self.cache_read_tokens)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def median_work_time(self) -> float:
|
|
56
|
+
"""Median work time in seconds (50th percentile)."""
|
|
57
|
+
if not self.work_times:
|
|
58
|
+
return 0.0
|
|
59
|
+
sorted_times = sorted(self.work_times)
|
|
60
|
+
n = len(sorted_times)
|
|
61
|
+
if n % 2 == 0:
|
|
62
|
+
return (sorted_times[n // 2 - 1] + sorted_times[n // 2]) / 2
|
|
63
|
+
return sorted_times[n // 2]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class HistoryEntry:
|
|
68
|
+
"""A single interaction from Claude Code history."""
|
|
69
|
+
display: str
|
|
70
|
+
timestamp_ms: int
|
|
71
|
+
project: Optional[str]
|
|
72
|
+
session_id: Optional[str]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def timestamp(self) -> datetime:
|
|
76
|
+
"""Convert millisecond timestamp to datetime."""
|
|
77
|
+
return datetime.fromtimestamp(self.timestamp_ms / 1000)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_history(history_path: Path = CLAUDE_HISTORY_PATH) -> List[HistoryEntry]:
|
|
81
|
+
"""Read all entries from history.jsonl.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
history_path: Path to history file (defaults to ~/.claude/history.jsonl)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of HistoryEntry objects, oldest first
|
|
88
|
+
"""
|
|
89
|
+
if not history_path.exists():
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
entries = []
|
|
93
|
+
try:
|
|
94
|
+
with open(history_path, 'r') as f:
|
|
95
|
+
for line in f:
|
|
96
|
+
line = line.strip()
|
|
97
|
+
if not line:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(line)
|
|
101
|
+
entry = HistoryEntry(
|
|
102
|
+
display=data.get("display", ""),
|
|
103
|
+
timestamp_ms=data.get("timestamp", 0),
|
|
104
|
+
project=data.get("project"),
|
|
105
|
+
session_id=data.get("sessionId"),
|
|
106
|
+
)
|
|
107
|
+
entries.append(entry)
|
|
108
|
+
except (json.JSONDecodeError, KeyError):
|
|
109
|
+
# Skip malformed entries
|
|
110
|
+
continue
|
|
111
|
+
except IOError:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
return entries
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_interactions_for_session(
|
|
118
|
+
session: "Session",
|
|
119
|
+
history_path: Path = CLAUDE_HISTORY_PATH
|
|
120
|
+
) -> List[HistoryEntry]:
|
|
121
|
+
"""Get history entries matching a session.
|
|
122
|
+
|
|
123
|
+
Matches by:
|
|
124
|
+
1. Project path == session.start_directory
|
|
125
|
+
2. Timestamp >= session.start_time
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
session: The overcode Session to match
|
|
129
|
+
history_path: Path to history file
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of matching HistoryEntry objects
|
|
133
|
+
"""
|
|
134
|
+
if not session.start_directory:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
# Parse session start time
|
|
138
|
+
try:
|
|
139
|
+
session_start = datetime.fromisoformat(session.start_time)
|
|
140
|
+
session_start_ms = int(session_start.timestamp() * 1000)
|
|
141
|
+
except (ValueError, TypeError):
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
# Normalize the project path for comparison
|
|
145
|
+
session_dir = str(Path(session.start_directory).resolve())
|
|
146
|
+
|
|
147
|
+
entries = read_history(history_path)
|
|
148
|
+
matching = []
|
|
149
|
+
|
|
150
|
+
for entry in entries:
|
|
151
|
+
# Must be after session started
|
|
152
|
+
if entry.timestamp_ms < session_start_ms:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Must match project directory
|
|
156
|
+
if entry.project:
|
|
157
|
+
entry_dir = str(Path(entry.project).resolve())
|
|
158
|
+
if entry_dir == session_dir:
|
|
159
|
+
matching.append(entry)
|
|
160
|
+
|
|
161
|
+
return matching
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def count_interactions(
|
|
165
|
+
session: "Session",
|
|
166
|
+
history_path: Path = CLAUDE_HISTORY_PATH
|
|
167
|
+
) -> int:
|
|
168
|
+
"""Count interactions for a session.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
session: The overcode Session to count for
|
|
172
|
+
history_path: Path to history file
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Number of interactions (user prompts) for this session
|
|
176
|
+
"""
|
|
177
|
+
return len(get_interactions_for_session(session, history_path))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_session_ids_for_session(
|
|
181
|
+
session: "Session",
|
|
182
|
+
history_path: Path = CLAUDE_HISTORY_PATH
|
|
183
|
+
) -> List[str]:
|
|
184
|
+
"""Get unique Claude Code sessionIds for an overcode session.
|
|
185
|
+
|
|
186
|
+
One overcode session may span multiple Claude Code sessions
|
|
187
|
+
(if Claude is restarted in the same tmux window).
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
session: The overcode Session
|
|
191
|
+
history_path: Path to history file
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
List of unique sessionId strings
|
|
195
|
+
"""
|
|
196
|
+
entries = get_interactions_for_session(session, history_path)
|
|
197
|
+
session_ids = set()
|
|
198
|
+
for entry in entries:
|
|
199
|
+
if entry.session_id:
|
|
200
|
+
session_ids.add(entry.session_id)
|
|
201
|
+
return sorted(session_ids)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def encode_project_path(path: str) -> str:
|
|
205
|
+
"""Encode a project path to Claude Code's directory naming format.
|
|
206
|
+
|
|
207
|
+
Claude Code stores project data in directories named like:
|
|
208
|
+
/home/user/myproject -> -home-user-myproject
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
path: The project path to encode
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Encoded directory name
|
|
215
|
+
"""
|
|
216
|
+
# Resolve to absolute path and replace / with -
|
|
217
|
+
resolved = str(Path(path).resolve())
|
|
218
|
+
# Replace path separators with dashes, prepend dash
|
|
219
|
+
return resolved.replace("/", "-")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_session_file_path(
|
|
223
|
+
project_path: str,
|
|
224
|
+
session_id: str,
|
|
225
|
+
projects_path: Path = CLAUDE_PROJECTS_PATH
|
|
226
|
+
) -> Path:
|
|
227
|
+
"""Get the path to a Claude Code session JSONL file.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
project_path: The project directory path
|
|
231
|
+
session_id: The Claude Code sessionId
|
|
232
|
+
projects_path: Base path for Claude projects
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Path to the session JSONL file
|
|
236
|
+
"""
|
|
237
|
+
encoded = encode_project_path(project_path)
|
|
238
|
+
return projects_path / encoded / f"{session_id}.jsonl"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def read_token_usage_from_session_file(
|
|
242
|
+
session_file: Path,
|
|
243
|
+
since: Optional[datetime] = None
|
|
244
|
+
) -> dict:
|
|
245
|
+
"""Read token usage from a Claude Code session JSONL file.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
session_file: Path to the session JSONL file
|
|
249
|
+
since: Only count tokens from messages after this time
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Dict with input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
|
|
253
|
+
"""
|
|
254
|
+
totals = {
|
|
255
|
+
"input_tokens": 0,
|
|
256
|
+
"output_tokens": 0,
|
|
257
|
+
"cache_creation_tokens": 0,
|
|
258
|
+
"cache_read_tokens": 0,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if not session_file.exists():
|
|
262
|
+
return totals
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
with open(session_file, 'r') as f:
|
|
266
|
+
for line in f:
|
|
267
|
+
line = line.strip()
|
|
268
|
+
if not line:
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
data = json.loads(line)
|
|
272
|
+
# Only assistant messages have usage data
|
|
273
|
+
if data.get("type") == "assistant":
|
|
274
|
+
# Check timestamp if filtering by time
|
|
275
|
+
if since:
|
|
276
|
+
ts_str = data.get("timestamp")
|
|
277
|
+
if ts_str:
|
|
278
|
+
try:
|
|
279
|
+
# Parse ISO timestamp (e.g., "2026-01-02T06:56:01.975Z")
|
|
280
|
+
msg_time = datetime.fromisoformat(
|
|
281
|
+
ts_str.replace("Z", "+00:00")
|
|
282
|
+
).replace(tzinfo=None)
|
|
283
|
+
if msg_time < since:
|
|
284
|
+
continue
|
|
285
|
+
except (ValueError, TypeError):
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
message = data.get("message", {})
|
|
289
|
+
usage = message.get("usage", {})
|
|
290
|
+
if usage:
|
|
291
|
+
totals["input_tokens"] += usage.get("input_tokens", 0)
|
|
292
|
+
totals["output_tokens"] += usage.get("output_tokens", 0)
|
|
293
|
+
totals["cache_creation_tokens"] += usage.get(
|
|
294
|
+
"cache_creation_input_tokens", 0
|
|
295
|
+
)
|
|
296
|
+
totals["cache_read_tokens"] += usage.get(
|
|
297
|
+
"cache_read_input_tokens", 0
|
|
298
|
+
)
|
|
299
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
300
|
+
continue
|
|
301
|
+
except IOError:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
return totals
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def read_work_times_from_session_file(
|
|
308
|
+
session_file: Path,
|
|
309
|
+
since: Optional[datetime] = None
|
|
310
|
+
) -> List[float]:
|
|
311
|
+
"""Calculate work times from a Claude Code session file.
|
|
312
|
+
|
|
313
|
+
Work time = time from one user prompt to the next user prompt.
|
|
314
|
+
This represents how long the agent worked autonomously.
|
|
315
|
+
|
|
316
|
+
Only counts actual user prompts (not tool results which are automatic).
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
session_file: Path to the session JSONL file
|
|
320
|
+
since: Only count work times from messages after this time
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
List of work times in seconds
|
|
324
|
+
"""
|
|
325
|
+
if not session_file.exists():
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
user_prompt_times: List[datetime] = []
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
with open(session_file, 'r') as f:
|
|
332
|
+
for line in f:
|
|
333
|
+
line = line.strip()
|
|
334
|
+
if not line:
|
|
335
|
+
continue
|
|
336
|
+
try:
|
|
337
|
+
data = json.loads(line)
|
|
338
|
+
if data.get("type") != "user":
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
# Check if this is an actual user prompt (not a tool result)
|
|
342
|
+
message = data.get("message", {})
|
|
343
|
+
content = message.get("content", "")
|
|
344
|
+
|
|
345
|
+
# Tool results have content as a list with tool_result type
|
|
346
|
+
if isinstance(content, list):
|
|
347
|
+
# Check if it's a tool result
|
|
348
|
+
if content and content[0].get("type") == "tool_result":
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
# Parse timestamp
|
|
352
|
+
ts_str = data.get("timestamp")
|
|
353
|
+
if not ts_str:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
msg_time = datetime.fromisoformat(
|
|
358
|
+
ts_str.replace("Z", "+00:00")
|
|
359
|
+
).replace(tzinfo=None)
|
|
360
|
+
|
|
361
|
+
# Filter by since time
|
|
362
|
+
if since and msg_time < since:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
user_prompt_times.append(msg_time)
|
|
366
|
+
except (ValueError, TypeError):
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
370
|
+
continue
|
|
371
|
+
except IOError:
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
# Calculate durations between consecutive prompts
|
|
375
|
+
work_times = []
|
|
376
|
+
for i in range(1, len(user_prompt_times)):
|
|
377
|
+
duration = (user_prompt_times[i] - user_prompt_times[i - 1]).total_seconds()
|
|
378
|
+
if duration > 0:
|
|
379
|
+
work_times.append(duration)
|
|
380
|
+
|
|
381
|
+
return work_times
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_session_stats(
|
|
385
|
+
session: "Session",
|
|
386
|
+
history_path: Path = CLAUDE_HISTORY_PATH,
|
|
387
|
+
projects_path: Path = CLAUDE_PROJECTS_PATH
|
|
388
|
+
) -> Optional[ClaudeSessionStats]:
|
|
389
|
+
"""Get comprehensive stats for an overcode session.
|
|
390
|
+
|
|
391
|
+
Combines interaction counting with token usage from session files.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
session: The overcode Session
|
|
395
|
+
history_path: Path to history.jsonl
|
|
396
|
+
projects_path: Path to Claude projects directory
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
ClaudeSessionStats if session has start_directory, None otherwise
|
|
400
|
+
"""
|
|
401
|
+
if not session.start_directory:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
# Parse session start time for filtering
|
|
405
|
+
try:
|
|
406
|
+
session_start = datetime.fromisoformat(session.start_time)
|
|
407
|
+
except (ValueError, TypeError):
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
# Get interaction count and session IDs
|
|
411
|
+
interactions = get_interactions_for_session(session, history_path)
|
|
412
|
+
interaction_count = len(interactions)
|
|
413
|
+
|
|
414
|
+
# Get unique session IDs
|
|
415
|
+
session_ids = set()
|
|
416
|
+
for entry in interactions:
|
|
417
|
+
if entry.session_id:
|
|
418
|
+
session_ids.add(entry.session_id)
|
|
419
|
+
|
|
420
|
+
# Sum token usage and work times across all session files
|
|
421
|
+
total_input = 0
|
|
422
|
+
total_output = 0
|
|
423
|
+
total_cache_creation = 0
|
|
424
|
+
total_cache_read = 0
|
|
425
|
+
all_work_times: List[float] = []
|
|
426
|
+
|
|
427
|
+
for sid in session_ids:
|
|
428
|
+
session_file = get_session_file_path(
|
|
429
|
+
session.start_directory, sid, projects_path
|
|
430
|
+
)
|
|
431
|
+
usage = read_token_usage_from_session_file(session_file, since=session_start)
|
|
432
|
+
total_input += usage["input_tokens"]
|
|
433
|
+
total_output += usage["output_tokens"]
|
|
434
|
+
total_cache_creation += usage["cache_creation_tokens"]
|
|
435
|
+
total_cache_read += usage["cache_read_tokens"]
|
|
436
|
+
|
|
437
|
+
# Collect work times from this session file
|
|
438
|
+
work_times = read_work_times_from_session_file(session_file, since=session_start)
|
|
439
|
+
all_work_times.extend(work_times)
|
|
440
|
+
|
|
441
|
+
return ClaudeSessionStats(
|
|
442
|
+
interaction_count=interaction_count,
|
|
443
|
+
input_tokens=total_input,
|
|
444
|
+
output_tokens=total_output,
|
|
445
|
+
cache_creation_tokens=total_cache_creation,
|
|
446
|
+
cache_read_tokens=total_cache_read,
|
|
447
|
+
work_times=all_work_times,
|
|
448
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real implementations of protocol interfaces.
|
|
3
|
+
|
|
4
|
+
These are production implementations that make actual subprocess calls
|
|
5
|
+
to tmux, perform real file I/O, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, List, Dict, Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RealTmux:
|
|
17
|
+
"""Production implementation of TmuxInterface using subprocess"""
|
|
18
|
+
|
|
19
|
+
def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["tmux", "capture-pane", "-t", f"{session}:{window}",
|
|
23
|
+
"-p", "-S", f"-{lines}"],
|
|
24
|
+
capture_output=True, text=True, timeout=5
|
|
25
|
+
)
|
|
26
|
+
if result.returncode == 0:
|
|
27
|
+
return result.stdout
|
|
28
|
+
return None
|
|
29
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def send_keys(self, session: str, window: int, keys: str, enter: bool = True) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
# For Claude Code: text and Enter must be sent as SEPARATE commands
|
|
35
|
+
# with a small delay, otherwise Claude Code doesn't process the Enter.
|
|
36
|
+
target = f"{session}:{window}"
|
|
37
|
+
|
|
38
|
+
# Send text first (if any)
|
|
39
|
+
if keys:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["tmux", "send-keys", "-t", target, keys],
|
|
42
|
+
timeout=5, capture_output=True
|
|
43
|
+
)
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
return False
|
|
46
|
+
# Small delay for Claude Code to process text
|
|
47
|
+
time.sleep(0.1)
|
|
48
|
+
|
|
49
|
+
# Send Enter separately
|
|
50
|
+
if enter:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["tmux", "send-keys", "-t", target, "Enter"],
|
|
53
|
+
timeout=5, capture_output=True
|
|
54
|
+
)
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
return True
|
|
59
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def has_session(self, session: str) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["tmux", "has-session", "-t", session],
|
|
66
|
+
capture_output=True, timeout=5
|
|
67
|
+
)
|
|
68
|
+
return result.returncode == 0
|
|
69
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def new_session(self, session: str) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
["tmux", "new-session", "-d", "-s", session],
|
|
76
|
+
capture_output=True, timeout=5
|
|
77
|
+
)
|
|
78
|
+
return result.returncode == 0
|
|
79
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def new_window(self, session: str, name: str, command: Optional[List[str]] = None,
|
|
83
|
+
cwd: Optional[str] = None) -> Optional[int]:
|
|
84
|
+
try:
|
|
85
|
+
cmd = ["tmux", "new-window", "-t", session, "-n", name, "-P", "-F", "#{window_index}"]
|
|
86
|
+
if cwd:
|
|
87
|
+
cmd.extend(["-c", cwd])
|
|
88
|
+
if command:
|
|
89
|
+
cmd.append(" ".join(command))
|
|
90
|
+
|
|
91
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
92
|
+
if result.returncode == 0:
|
|
93
|
+
return int(result.stdout.strip())
|
|
94
|
+
return None
|
|
95
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def kill_window(self, session: str, window: int) -> bool:
|
|
99
|
+
try:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
["tmux", "kill-window", "-t", f"{session}:{window}"],
|
|
102
|
+
capture_output=True, timeout=5
|
|
103
|
+
)
|
|
104
|
+
return result.returncode == 0
|
|
105
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
def kill_session(self, session: str) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
["tmux", "kill-session", "-t", session],
|
|
112
|
+
capture_output=True, timeout=5
|
|
113
|
+
)
|
|
114
|
+
return result.returncode == 0
|
|
115
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def list_windows(self, session: str) -> List[Dict[str, Any]]:
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
["tmux", "list-windows", "-t", session, "-F",
|
|
122
|
+
"#{window_index}:#{window_name}:#{window_active}"],
|
|
123
|
+
capture_output=True, text=True, timeout=5
|
|
124
|
+
)
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
windows = []
|
|
129
|
+
for line in result.stdout.strip().split('\n'):
|
|
130
|
+
if line:
|
|
131
|
+
parts = line.split(':')
|
|
132
|
+
if len(parts) >= 3:
|
|
133
|
+
windows.append({
|
|
134
|
+
'index': int(parts[0]),
|
|
135
|
+
'name': parts[1],
|
|
136
|
+
'active': parts[2] == '1'
|
|
137
|
+
})
|
|
138
|
+
return windows
|
|
139
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
def attach(self, session: str) -> None:
|
|
143
|
+
os.execlp("tmux", "tmux", "attach-session", "-t", session)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class RealFileSystem:
|
|
147
|
+
"""Production implementation of FileSystemInterface"""
|
|
148
|
+
|
|
149
|
+
def read_json(self, path: Path) -> Optional[Dict[str, Any]]:
|
|
150
|
+
try:
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return None
|
|
153
|
+
with open(path, 'r') as f:
|
|
154
|
+
return json.load(f)
|
|
155
|
+
except (json.JSONDecodeError, IOError):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def write_json(self, path: Path, data: Dict[str, Any]) -> bool:
|
|
159
|
+
try:
|
|
160
|
+
# Write atomically via temp file
|
|
161
|
+
temp_path = path.with_suffix('.tmp')
|
|
162
|
+
with open(temp_path, 'w') as f:
|
|
163
|
+
json.dump(data, f, indent=2)
|
|
164
|
+
temp_path.replace(path)
|
|
165
|
+
return True
|
|
166
|
+
except IOError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def exists(self, path: Path) -> bool:
|
|
170
|
+
return path.exists()
|
|
171
|
+
|
|
172
|
+
def mkdir(self, path: Path, parents: bool = True) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
path.mkdir(parents=parents, exist_ok=True)
|
|
175
|
+
return True
|
|
176
|
+
except IOError:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def read_text(self, path: Path) -> Optional[str]:
|
|
180
|
+
try:
|
|
181
|
+
return path.read_text()
|
|
182
|
+
except IOError:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def write_text(self, path: Path, content: str) -> bool:
|
|
186
|
+
try:
|
|
187
|
+
path.write_text(content)
|
|
188
|
+
return True
|
|
189
|
+
except IOError:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class RealSubprocess:
|
|
194
|
+
"""Production implementation of SubprocessInterface"""
|
|
195
|
+
|
|
196
|
+
def run(self, cmd: List[str], timeout: Optional[int] = None,
|
|
197
|
+
capture_output: bool = True) -> Optional[Dict[str, Any]]:
|
|
198
|
+
try:
|
|
199
|
+
result = subprocess.run(
|
|
200
|
+
cmd, timeout=timeout, capture_output=capture_output, text=True
|
|
201
|
+
)
|
|
202
|
+
return {
|
|
203
|
+
'returncode': result.returncode,
|
|
204
|
+
'stdout': result.stdout if capture_output else '',
|
|
205
|
+
'stderr': result.stderr if capture_output else ''
|
|
206
|
+
}
|
|
207
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def popen(self, cmd: List[str], cwd: Optional[str] = None) -> Any:
|
|
211
|
+
try:
|
|
212
|
+
return subprocess.Popen(cmd, cwd=cwd)
|
|
213
|
+
except subprocess.SubprocessError:
|
|
214
|
+
return None
|
overcode/interfaces.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocol definitions and implementations for external dependencies.
|
|
3
|
+
|
|
4
|
+
This module re-exports from the split modules for backward compatibility:
|
|
5
|
+
- protocols.py: Protocol (interface) definitions
|
|
6
|
+
- implementations.py: Real (production) implementations
|
|
7
|
+
- mocks.py: Mock implementations for testing
|
|
8
|
+
|
|
9
|
+
New code should import directly from the specific modules:
|
|
10
|
+
from overcode.protocols import TmuxInterface
|
|
11
|
+
from overcode.implementations import RealTmux
|
|
12
|
+
from overcode.mocks import MockTmux
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Re-export protocols
|
|
16
|
+
from .protocols import (
|
|
17
|
+
TmuxInterface,
|
|
18
|
+
FileSystemInterface,
|
|
19
|
+
SubprocessInterface,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Re-export real implementations
|
|
23
|
+
from .implementations import (
|
|
24
|
+
RealTmux,
|
|
25
|
+
RealFileSystem,
|
|
26
|
+
RealSubprocess,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Re-export mocks
|
|
30
|
+
from .mocks import (
|
|
31
|
+
MockTmux,
|
|
32
|
+
MockFileSystem,
|
|
33
|
+
MockSubprocess,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Protocols
|
|
38
|
+
"TmuxInterface",
|
|
39
|
+
"FileSystemInterface",
|
|
40
|
+
"SubprocessInterface",
|
|
41
|
+
# Real implementations
|
|
42
|
+
"RealTmux",
|
|
43
|
+
"RealFileSystem",
|
|
44
|
+
"RealSubprocess",
|
|
45
|
+
# Mocks
|
|
46
|
+
"MockTmux",
|
|
47
|
+
"MockFileSystem",
|
|
48
|
+
"MockSubprocess",
|
|
49
|
+
]
|