claude-team-mcp 0.4.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.
- claude_team_mcp/__init__.py +24 -0
- claude_team_mcp/__main__.py +8 -0
- claude_team_mcp/cli_backends/__init__.py +44 -0
- claude_team_mcp/cli_backends/base.py +132 -0
- claude_team_mcp/cli_backends/claude.py +110 -0
- claude_team_mcp/cli_backends/codex.py +110 -0
- claude_team_mcp/colors.py +108 -0
- claude_team_mcp/formatting.py +120 -0
- claude_team_mcp/idle_detection.py +488 -0
- claude_team_mcp/iterm_utils.py +1119 -0
- claude_team_mcp/names.py +427 -0
- claude_team_mcp/profile.py +364 -0
- claude_team_mcp/registry.py +426 -0
- claude_team_mcp/schemas/__init__.py +5 -0
- claude_team_mcp/schemas/codex.py +267 -0
- claude_team_mcp/server.py +390 -0
- claude_team_mcp/session_state.py +1058 -0
- claude_team_mcp/subprocess_cache.py +119 -0
- claude_team_mcp/tools/__init__.py +52 -0
- claude_team_mcp/tools/adopt_worker.py +122 -0
- claude_team_mcp/tools/annotate_worker.py +57 -0
- claude_team_mcp/tools/bd_help.py +42 -0
- claude_team_mcp/tools/check_idle_workers.py +98 -0
- claude_team_mcp/tools/close_workers.py +194 -0
- claude_team_mcp/tools/discover_workers.py +129 -0
- claude_team_mcp/tools/examine_worker.py +56 -0
- claude_team_mcp/tools/list_workers.py +76 -0
- claude_team_mcp/tools/list_worktrees.py +106 -0
- claude_team_mcp/tools/message_workers.py +311 -0
- claude_team_mcp/tools/read_worker_logs.py +158 -0
- claude_team_mcp/tools/spawn_workers.py +634 -0
- claude_team_mcp/tools/wait_idle_workers.py +148 -0
- claude_team_mcp/utils/__init__.py +17 -0
- claude_team_mcp/utils/constants.py +87 -0
- claude_team_mcp/utils/errors.py +87 -0
- claude_team_mcp/utils/worktree_detection.py +79 -0
- claude_team_mcp/worker_prompt.py +350 -0
- claude_team_mcp/worktree.py +532 -0
- claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
- claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
- claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
- claude_team_mcp-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Session State Parser
|
|
3
|
+
|
|
4
|
+
Parse Claude Code session JSONL files to read conversation state.
|
|
5
|
+
Extracted and adapted from session_parser.py for use in the MCP server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Claude projects directory
|
|
17
|
+
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_timestamp(entry: dict) -> datetime:
|
|
21
|
+
"""Parse ISO timestamp from JSONL entry, handling Z suffix."""
|
|
22
|
+
try:
|
|
23
|
+
return datetime.fromisoformat(
|
|
24
|
+
entry.get("timestamp", "").replace("Z", "+00:00")
|
|
25
|
+
)
|
|
26
|
+
except (ValueError, AttributeError):
|
|
27
|
+
return datetime.now()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Data Classes
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Message:
|
|
36
|
+
"""A single message from a Claude session."""
|
|
37
|
+
|
|
38
|
+
uuid: str
|
|
39
|
+
parent_uuid: Optional[str]
|
|
40
|
+
role: str # "user" or "assistant"
|
|
41
|
+
content: str # Extracted text content
|
|
42
|
+
timestamp: datetime
|
|
43
|
+
tool_uses: list = field(default_factory=list)
|
|
44
|
+
thinking: Optional[str] = None # Thinking block content if present
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
preview = self.content[:40] + "..." if len(self.content) > 40 else self.content
|
|
48
|
+
return f"Message({self.role}: {preview!r})"
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
"""Convert to dictionary for JSON serialization."""
|
|
52
|
+
result: dict[str, Any] = {
|
|
53
|
+
"role": self.role,
|
|
54
|
+
"content": self.content,
|
|
55
|
+
"timestamp": self.timestamp.isoformat(),
|
|
56
|
+
}
|
|
57
|
+
if self.tool_uses:
|
|
58
|
+
result["tool_uses"] = self.tool_uses
|
|
59
|
+
if self.thinking:
|
|
60
|
+
result["thinking"] = self.thinking
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class SessionState:
|
|
66
|
+
"""Parsed state of a Claude session from its JSONL file."""
|
|
67
|
+
|
|
68
|
+
session_id: str
|
|
69
|
+
project_path: str
|
|
70
|
+
jsonl_path: Path
|
|
71
|
+
messages: list[Message] = field(default_factory=list)
|
|
72
|
+
last_modified: float = 0
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def last_user_message(self) -> Optional[Message]:
|
|
76
|
+
"""Get the most recent user message."""
|
|
77
|
+
for msg in reversed(self.messages):
|
|
78
|
+
if msg.role == "user":
|
|
79
|
+
return msg
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def last_assistant_message(self) -> Optional[Message]:
|
|
84
|
+
"""Get the most recent assistant message with text content."""
|
|
85
|
+
for msg in reversed(self.messages):
|
|
86
|
+
if msg.role == "assistant" and msg.content:
|
|
87
|
+
return msg
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def conversation(self) -> list[Message]:
|
|
92
|
+
"""Get only user/assistant messages with content."""
|
|
93
|
+
return [m for m in self.messages if m.role in ("user", "assistant") and m.content]
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def message_count(self) -> int:
|
|
97
|
+
"""Total number of conversation messages."""
|
|
98
|
+
return len(self.conversation)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# =============================================================================
|
|
102
|
+
# Path Utilities
|
|
103
|
+
# =============================================================================
|
|
104
|
+
|
|
105
|
+
def get_project_slug(project_path: str) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Convert a filesystem path to Claude's project directory slug.
|
|
108
|
+
|
|
109
|
+
Claude replaces both / and . with - to create directory names.
|
|
110
|
+
Example: /Users/josh/code -> -Users-josh-code
|
|
111
|
+
Example: /path/.worktrees/foo -> -path--worktrees-foo
|
|
112
|
+
"""
|
|
113
|
+
return project_path.replace("/", "-").replace(".", "-")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def unslugify_path(slug: str) -> str | None:
|
|
117
|
+
"""
|
|
118
|
+
Convert a Claude project slug back to a filesystem path.
|
|
119
|
+
|
|
120
|
+
The slug replaces / with -, but project names can also contain -.
|
|
121
|
+
We resolve the ambiguity by checking which paths actually exist.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
slug: Claude project directory slug (e.g., "-Users-phaedrus-Projects-myproject")
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The original filesystem path if it can be determined, None otherwise.
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
"-Users-phaedrus-Projects-claude-iterm-controller"
|
|
131
|
+
-> "/Users/phaedrus/Projects/claude-iterm-controller"
|
|
132
|
+
"""
|
|
133
|
+
if not slug.startswith("-"):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
# Handle dotfile directories specifically
|
|
137
|
+
# The slug replaces both / and . with -, so /.worktrees becomes --worktrees
|
|
138
|
+
# We handle known cases to avoid unexpected behavior elsewhere
|
|
139
|
+
slug = slug.replace("--worktrees", "-.worktrees")
|
|
140
|
+
slug = slug.replace("--claude-team", "-.claude-team")
|
|
141
|
+
|
|
142
|
+
# Split the slug into parts (removing the leading -)
|
|
143
|
+
# Each part was originally separated by / or is part of a hyphenated name
|
|
144
|
+
parts = slug[1:].split("-")
|
|
145
|
+
|
|
146
|
+
# Greedy algorithm: at each step, try to find the longest sequence
|
|
147
|
+
# of parts that forms an existing directory (or the final path component)
|
|
148
|
+
result_parts: list[str] = []
|
|
149
|
+
i = 0
|
|
150
|
+
|
|
151
|
+
while i < len(parts):
|
|
152
|
+
found = False
|
|
153
|
+
# Try longest possible component first (most hyphens preserved)
|
|
154
|
+
for j in range(len(parts), i, -1):
|
|
155
|
+
candidate_component = "-".join(parts[i:j])
|
|
156
|
+
candidate_path = "/" + "/".join(result_parts + [candidate_component])
|
|
157
|
+
|
|
158
|
+
# For the final component, check if path exists (file or dir)
|
|
159
|
+
# For intermediate components, must be a directory
|
|
160
|
+
if j == len(parts):
|
|
161
|
+
if Path(candidate_path).exists():
|
|
162
|
+
result_parts.append(candidate_component)
|
|
163
|
+
i = j
|
|
164
|
+
found = True
|
|
165
|
+
break
|
|
166
|
+
else:
|
|
167
|
+
if Path(candidate_path).is_dir():
|
|
168
|
+
result_parts.append(candidate_component)
|
|
169
|
+
i = j
|
|
170
|
+
found = True
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
if not found:
|
|
174
|
+
# No existing path found, use single part and continue
|
|
175
|
+
result_parts.append(parts[i])
|
|
176
|
+
i += 1
|
|
177
|
+
|
|
178
|
+
final_path = "/" + "/".join(result_parts)
|
|
179
|
+
return final_path if Path(final_path).exists() else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_project_dir(project_path: str) -> Path:
|
|
183
|
+
"""
|
|
184
|
+
Get the Claude projects directory for a given project path.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
project_path: Absolute path to the project
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Path to the Claude projects directory for this project
|
|
191
|
+
"""
|
|
192
|
+
return CLAUDE_PROJECTS_DIR / get_project_slug(project_path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# =============================================================================
|
|
196
|
+
# Session Markers for JSONL Correlation
|
|
197
|
+
# =============================================================================
|
|
198
|
+
|
|
199
|
+
# Marker format for correlating iTerm sessions with JSONL files
|
|
200
|
+
MARKER_PREFIX = "<!claude-team-session:"
|
|
201
|
+
MARKER_SUFFIX = "!>"
|
|
202
|
+
|
|
203
|
+
# iTerm-specific marker for session discovery/recovery
|
|
204
|
+
# When running in iTerm, we emit both markers so that orphaned sessions
|
|
205
|
+
# can be matched back to their JSONL files even after MCP server restart.
|
|
206
|
+
# Future terminal support (e.g., Zed) will use their own marker prefix.
|
|
207
|
+
ITERM_MARKER_PREFIX = "<!claude-team-iterm:"
|
|
208
|
+
ITERM_MARKER_SUFFIX = "!>"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def generate_marker_message(
|
|
212
|
+
session_id: str,
|
|
213
|
+
iterm_session_id: Optional[str] = None,
|
|
214
|
+
) -> str:
|
|
215
|
+
"""
|
|
216
|
+
Generate a marker message to send to a session for JSONL correlation.
|
|
217
|
+
|
|
218
|
+
The marker is used to identify which JSONL file belongs to which
|
|
219
|
+
iTerm session when multiple sessions exist for the same project.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
session_id: The managed session ID (e.g., "worker-1")
|
|
223
|
+
iterm_session_id: Optional iTerm2 session ID for discovery/recovery.
|
|
224
|
+
When provided, an additional iTerm-specific marker is emitted.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
A message string to send to the session
|
|
228
|
+
"""
|
|
229
|
+
marker = f"{MARKER_PREFIX}{session_id}{MARKER_SUFFIX}"
|
|
230
|
+
|
|
231
|
+
# Add iTerm-specific marker if provided (for session recovery after MCP restart)
|
|
232
|
+
if iterm_session_id:
|
|
233
|
+
marker += f"\n{ITERM_MARKER_PREFIX}{iterm_session_id}{ITERM_MARKER_SUFFIX}"
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
f"{marker}\n\n"
|
|
237
|
+
"The above is a marker that assists Claude Teams in locating your session - "
|
|
238
|
+
"respond with ONLY the word 'Identified!' and nothing further. "
|
|
239
|
+
"Please forgive the interruption."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def extract_marker_session_id(text: str) -> Optional[str]:
|
|
244
|
+
"""
|
|
245
|
+
Extract a session ID from marker text if present.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
text: Text that may contain a marker
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
The session ID from the marker, or None if no marker found
|
|
252
|
+
"""
|
|
253
|
+
start = text.find(MARKER_PREFIX)
|
|
254
|
+
if start == -1:
|
|
255
|
+
return None
|
|
256
|
+
start += len(MARKER_PREFIX)
|
|
257
|
+
end = text.find(MARKER_SUFFIX, start)
|
|
258
|
+
if end == -1:
|
|
259
|
+
return None
|
|
260
|
+
return text[start:end]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def extract_iterm_session_id(text: str) -> Optional[str]:
|
|
264
|
+
"""
|
|
265
|
+
Extract an iTerm session ID from marker text if present.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
text: Text that may contain an iTerm marker
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The iTerm session ID from the marker, or None if no marker found
|
|
272
|
+
"""
|
|
273
|
+
start = text.find(ITERM_MARKER_PREFIX)
|
|
274
|
+
if start == -1:
|
|
275
|
+
return None
|
|
276
|
+
start += len(ITERM_MARKER_PREFIX)
|
|
277
|
+
end = text.find(ITERM_MARKER_SUFFIX, start)
|
|
278
|
+
if end == -1:
|
|
279
|
+
return None
|
|
280
|
+
return text[start:end]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def find_jsonl_by_marker(
|
|
284
|
+
project_path: str,
|
|
285
|
+
session_id: str,
|
|
286
|
+
max_age_seconds: int = 120,
|
|
287
|
+
) -> Optional[str]:
|
|
288
|
+
"""
|
|
289
|
+
Find a JSONL file that contains a specific session marker.
|
|
290
|
+
|
|
291
|
+
Scans recent JSONL files in the project directory looking for
|
|
292
|
+
the session marker, which correlates the JSONL to an iTerm session.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
project_path: Absolute path to the project
|
|
296
|
+
session_id: The session ID to search for in markers
|
|
297
|
+
max_age_seconds: Only check files modified within this many seconds
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
The Claude session ID (JSONL filename stem) if found, None otherwise
|
|
301
|
+
"""
|
|
302
|
+
project_dir = get_project_dir(project_path)
|
|
303
|
+
if not project_dir.exists():
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
marker = f"{MARKER_PREFIX}{session_id}{MARKER_SUFFIX}"
|
|
307
|
+
now = time.time()
|
|
308
|
+
|
|
309
|
+
# Check recent JSONL files
|
|
310
|
+
for f in project_dir.glob("*.jsonl"):
|
|
311
|
+
# Skip agent files
|
|
312
|
+
if f.name.startswith("agent-"):
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# Skip old files
|
|
316
|
+
if now - f.stat().st_mtime > max_age_seconds:
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
# Search for marker in file (check last portion for efficiency)
|
|
320
|
+
try:
|
|
321
|
+
# Read last 50KB of file (marker should be near the end)
|
|
322
|
+
file_size = f.stat().st_size
|
|
323
|
+
read_size = min(file_size, 50000)
|
|
324
|
+
with open(f, "r") as fp:
|
|
325
|
+
if file_size > read_size:
|
|
326
|
+
fp.seek(file_size - read_size)
|
|
327
|
+
content = fp.read()
|
|
328
|
+
|
|
329
|
+
if marker in content:
|
|
330
|
+
return f.stem
|
|
331
|
+
except Exception:
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass
|
|
338
|
+
class ItermSessionMatch:
|
|
339
|
+
"""Result of matching an iTerm session ID to a JSONL file."""
|
|
340
|
+
|
|
341
|
+
iterm_session_id: str
|
|
342
|
+
internal_session_id: str # Our claude-team session ID
|
|
343
|
+
jsonl_path: Path
|
|
344
|
+
project_path: str # Recovered from directory slug
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def find_jsonl_by_iterm_id(
|
|
348
|
+
iterm_session_id: str,
|
|
349
|
+
max_age_seconds: int = 3600,
|
|
350
|
+
) -> Optional[ItermSessionMatch]:
|
|
351
|
+
"""
|
|
352
|
+
Find a JSONL file containing a specific iTerm session marker.
|
|
353
|
+
|
|
354
|
+
Scans all project directories in ~/.claude/projects/ for JOSNLs
|
|
355
|
+
that contain the iTerm-specific marker. This enables session recovery
|
|
356
|
+
after MCP server restart.
|
|
357
|
+
|
|
358
|
+
Only looks at root user messages (type="user", parentUuid=null) and
|
|
359
|
+
extracts markers from the message.content field for reliability.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
iterm_session_id: The iTerm2 session ID to search for
|
|
363
|
+
max_age_seconds: Only check files modified within this many seconds
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
ItermSessionMatch with full recovery info, or None if not found
|
|
367
|
+
"""
|
|
368
|
+
iterm_marker = f"{ITERM_MARKER_PREFIX}{iterm_session_id}{ITERM_MARKER_SUFFIX}"
|
|
369
|
+
now = time.time()
|
|
370
|
+
|
|
371
|
+
# Scan all project directories
|
|
372
|
+
if not CLAUDE_PROJECTS_DIR.exists():
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
for project_dir in CLAUDE_PROJECTS_DIR.iterdir():
|
|
376
|
+
if not project_dir.is_dir():
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
# Check JSONL files in this project
|
|
380
|
+
for f in project_dir.glob("*.jsonl"):
|
|
381
|
+
# Skip agent files
|
|
382
|
+
if f.name.startswith("agent-"):
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# Skip old files
|
|
386
|
+
try:
|
|
387
|
+
if now - f.stat().st_mtime > max_age_seconds:
|
|
388
|
+
continue
|
|
389
|
+
except OSError:
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
# Parse JSONL looking for root user message with our markers
|
|
393
|
+
try:
|
|
394
|
+
with open(f, "r") as fp:
|
|
395
|
+
for line in fp:
|
|
396
|
+
line = line.strip()
|
|
397
|
+
if not line:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
entry = json.loads(line)
|
|
402
|
+
except json.JSONDecodeError:
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
# Only look at root user messages (our marker message)
|
|
406
|
+
if entry.get("type") != "user":
|
|
407
|
+
continue
|
|
408
|
+
if entry.get("parentUuid") is not None:
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
# Extract message content
|
|
412
|
+
message = entry.get("message", {})
|
|
413
|
+
content = message.get("content", "")
|
|
414
|
+
if not isinstance(content, str):
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
# Check for iTerm marker in message content
|
|
418
|
+
if iterm_marker not in content:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
# Extract internal session ID from the same content
|
|
422
|
+
internal_id = extract_marker_session_id(content)
|
|
423
|
+
if not internal_id:
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
# Recover project path from directory slug
|
|
427
|
+
project_path = unslugify_path(project_dir.name)
|
|
428
|
+
if not project_path:
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
return ItermSessionMatch(
|
|
432
|
+
iterm_session_id=iterm_session_id,
|
|
433
|
+
internal_session_id=internal_id,
|
|
434
|
+
jsonl_path=f,
|
|
435
|
+
project_path=project_path,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
except Exception:
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def await_marker_in_jsonl(
|
|
445
|
+
project_path: str,
|
|
446
|
+
session_id: str,
|
|
447
|
+
timeout: float = 30.0,
|
|
448
|
+
poll_interval: float = 0.1,
|
|
449
|
+
) -> Optional[str]:
|
|
450
|
+
"""
|
|
451
|
+
Poll for a session marker to appear in the JSONL.
|
|
452
|
+
|
|
453
|
+
The marker is logged as a user message the instant send_prompt() returns.
|
|
454
|
+
This function polls immediately (no initial delay) and returns as soon as
|
|
455
|
+
the marker is found.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
project_path: Absolute path to the project
|
|
459
|
+
session_id: The session ID to search for in markers
|
|
460
|
+
timeout: Maximum seconds to wait (default 30)
|
|
461
|
+
poll_interval: Seconds between polls (default 0.1)
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
The Claude session ID (JSONL filename stem) if found, None on timeout
|
|
465
|
+
"""
|
|
466
|
+
import asyncio
|
|
467
|
+
|
|
468
|
+
start = time.time()
|
|
469
|
+
|
|
470
|
+
while time.time() - start < timeout:
|
|
471
|
+
result = find_jsonl_by_marker(project_path, session_id)
|
|
472
|
+
if result:
|
|
473
|
+
return result
|
|
474
|
+
await asyncio.sleep(poll_interval)
|
|
475
|
+
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# =============================================================================
|
|
480
|
+
# Session Discovery
|
|
481
|
+
# =============================================================================
|
|
482
|
+
|
|
483
|
+
def list_sessions(project_path: str) -> list[tuple[str, Path, float]]:
|
|
484
|
+
"""
|
|
485
|
+
List all Claude sessions for a project.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
project_path: Absolute path to the project
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
List of (session_id, jsonl_path, mtime) sorted by mtime desc
|
|
492
|
+
"""
|
|
493
|
+
project_dir = get_project_dir(project_path)
|
|
494
|
+
if not project_dir.exists():
|
|
495
|
+
return []
|
|
496
|
+
|
|
497
|
+
sessions = []
|
|
498
|
+
for f in project_dir.glob("*.jsonl"):
|
|
499
|
+
# Skip agent-* files (subagents)
|
|
500
|
+
if f.name.startswith("agent-"):
|
|
501
|
+
continue
|
|
502
|
+
sessions.append((f.stem, f, f.stat().st_mtime))
|
|
503
|
+
|
|
504
|
+
return sorted(sessions, key=lambda x: x[2], reverse=True)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def find_active_session(project_path: str, max_age_seconds: int = 300) -> Optional[str]:
|
|
508
|
+
"""
|
|
509
|
+
Find the most recently active session (modified within max_age_seconds).
|
|
510
|
+
|
|
511
|
+
Useful for identifying which JSONL file corresponds to a running Claude instance.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
project_path: Absolute path to the project
|
|
515
|
+
max_age_seconds: Maximum age in seconds to consider "active"
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Session ID string, or None if no active session found
|
|
519
|
+
"""
|
|
520
|
+
sessions = list_sessions(project_path)
|
|
521
|
+
if not sessions:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
session_id, _, mtime = sessions[0]
|
|
525
|
+
if time.time() - mtime < max_age_seconds:
|
|
526
|
+
return session_id
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# =============================================================================
|
|
531
|
+
# Session Parsing
|
|
532
|
+
# =============================================================================
|
|
533
|
+
|
|
534
|
+
def parse_session(jsonl_path: Path) -> SessionState:
|
|
535
|
+
"""
|
|
536
|
+
Parse a Claude session JSONL file into a SessionState object.
|
|
537
|
+
|
|
538
|
+
The JSONL format has one JSON object per line with structure:
|
|
539
|
+
{
|
|
540
|
+
"type": "user" | "assistant" | "file-history-snapshot",
|
|
541
|
+
"sessionId": "uuid",
|
|
542
|
+
"uuid": "message-uuid",
|
|
543
|
+
"parentUuid": "parent-uuid",
|
|
544
|
+
"message": { "role": "user"|"assistant", "content": [...] },
|
|
545
|
+
"timestamp": "ISO-8601",
|
|
546
|
+
"cwd": "/path/to/project"
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
jsonl_path: Path to the JSONL file
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Parsed SessionState object
|
|
554
|
+
"""
|
|
555
|
+
messages = []
|
|
556
|
+
session_id = jsonl_path.stem
|
|
557
|
+
project_path = ""
|
|
558
|
+
|
|
559
|
+
with open(jsonl_path, "r") as f:
|
|
560
|
+
for line in f:
|
|
561
|
+
line = line.strip()
|
|
562
|
+
if not line:
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
entry = json.loads(line)
|
|
567
|
+
except json.JSONDecodeError:
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
# Skip non-message entries
|
|
571
|
+
if entry.get("type") == "file-history-snapshot":
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
# Extract project path from cwd if available
|
|
575
|
+
if "cwd" in entry and not project_path:
|
|
576
|
+
project_path = entry["cwd"]
|
|
577
|
+
|
|
578
|
+
# Parse message content
|
|
579
|
+
message_data = entry.get("message", {})
|
|
580
|
+
role = message_data.get("role", "")
|
|
581
|
+
raw_content = message_data.get("content", [])
|
|
582
|
+
|
|
583
|
+
# Extract text content, tool uses, and thinking blocks
|
|
584
|
+
if isinstance(raw_content, str):
|
|
585
|
+
text_content = raw_content
|
|
586
|
+
tool_uses = []
|
|
587
|
+
thinking_content = None
|
|
588
|
+
else:
|
|
589
|
+
text_parts = []
|
|
590
|
+
tool_uses = []
|
|
591
|
+
thinking_parts = []
|
|
592
|
+
for item in raw_content:
|
|
593
|
+
if isinstance(item, dict):
|
|
594
|
+
if item.get("type") == "text":
|
|
595
|
+
text_parts.append(item.get("text", ""))
|
|
596
|
+
elif item.get("type") == "tool_use":
|
|
597
|
+
tool_uses.append(
|
|
598
|
+
{
|
|
599
|
+
"id": item.get("id"),
|
|
600
|
+
"name": item.get("name"),
|
|
601
|
+
"input": item.get("input", {}),
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
elif item.get("type") == "thinking":
|
|
605
|
+
thinking_parts.append(item.get("thinking", ""))
|
|
606
|
+
text_content = "\n".join(text_parts)
|
|
607
|
+
thinking_content = "\n".join(thinking_parts) if thinking_parts else None
|
|
608
|
+
|
|
609
|
+
ts = parse_timestamp(entry)
|
|
610
|
+
|
|
611
|
+
messages.append(
|
|
612
|
+
Message(
|
|
613
|
+
uuid=entry.get("uuid", ""),
|
|
614
|
+
parent_uuid=entry.get("parentUuid"),
|
|
615
|
+
role=role,
|
|
616
|
+
content=text_content,
|
|
617
|
+
timestamp=ts,
|
|
618
|
+
tool_uses=tool_uses,
|
|
619
|
+
thinking=thinking_content,
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
return SessionState(
|
|
624
|
+
session_id=session_id,
|
|
625
|
+
project_path=project_path,
|
|
626
|
+
jsonl_path=jsonl_path,
|
|
627
|
+
messages=messages,
|
|
628
|
+
last_modified=jsonl_path.stat().st_mtime if jsonl_path.exists() else 0,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# =============================================================================
|
|
633
|
+
# Codex Session Parsing
|
|
634
|
+
# =============================================================================
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def parse_codex_session(jsonl_path: Path) -> SessionState:
|
|
638
|
+
"""
|
|
639
|
+
Parse a Codex session JSONL file into a SessionState object.
|
|
640
|
+
|
|
641
|
+
Codex has a different JSONL format than Claude Code:
|
|
642
|
+
- Interactive mode uses event_msg and response_item wrappers
|
|
643
|
+
- Exec mode uses direct ThreadEvent types (item.completed, etc.)
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
jsonl_path: Path to the Codex JSONL file
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
Parsed SessionState object with messages extracted
|
|
650
|
+
"""
|
|
651
|
+
messages = []
|
|
652
|
+
session_id = jsonl_path.stem
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
with open(jsonl_path, "r") as f:
|
|
656
|
+
for line_num, line in enumerate(f):
|
|
657
|
+
line = line.strip()
|
|
658
|
+
if not line:
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
data = json.loads(line)
|
|
663
|
+
except json.JSONDecodeError:
|
|
664
|
+
continue
|
|
665
|
+
|
|
666
|
+
msg = _parse_codex_event(data, line_num)
|
|
667
|
+
if msg:
|
|
668
|
+
messages.append(msg)
|
|
669
|
+
|
|
670
|
+
except FileNotFoundError:
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
return SessionState(
|
|
674
|
+
session_id=session_id,
|
|
675
|
+
project_path="", # Codex doesn't have project path in the same way
|
|
676
|
+
jsonl_path=jsonl_path,
|
|
677
|
+
messages=messages,
|
|
678
|
+
last_modified=jsonl_path.stat().st_mtime if jsonl_path.exists() else 0,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _parse_codex_event(data: dict, line_num: int) -> Optional[Message]:
|
|
683
|
+
"""
|
|
684
|
+
Parse a single Codex JSONL event into a Message if applicable.
|
|
685
|
+
|
|
686
|
+
Handles both interactive mode format (wrapped events) and exec mode format
|
|
687
|
+
(direct ThreadEvent types).
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
data: Parsed JSON dict from JSONL line
|
|
691
|
+
line_num: Line number for UUID generation
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
Message object if this event represents a message, None otherwise
|
|
695
|
+
"""
|
|
696
|
+
event_type = data.get("type", "")
|
|
697
|
+
now = datetime.now()
|
|
698
|
+
|
|
699
|
+
# Interactive mode: event_msg wrapper
|
|
700
|
+
if event_type == "event_msg":
|
|
701
|
+
payload = data.get("payload", {})
|
|
702
|
+
payload_type = payload.get("type")
|
|
703
|
+
|
|
704
|
+
if payload_type == "agent_message":
|
|
705
|
+
# Agent response message
|
|
706
|
+
text = payload.get("text", "")
|
|
707
|
+
if text:
|
|
708
|
+
return Message(
|
|
709
|
+
uuid=payload.get("id", f"codex-{line_num}"),
|
|
710
|
+
parent_uuid=None,
|
|
711
|
+
role="assistant",
|
|
712
|
+
content=text,
|
|
713
|
+
timestamp=now,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
elif payload_type == "user_message":
|
|
717
|
+
# User input message
|
|
718
|
+
text = payload.get("text", "")
|
|
719
|
+
if text:
|
|
720
|
+
return Message(
|
|
721
|
+
uuid=payload.get("id", f"codex-user-{line_num}"),
|
|
722
|
+
parent_uuid=None,
|
|
723
|
+
role="user",
|
|
724
|
+
content=text,
|
|
725
|
+
timestamp=now,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
# Interactive mode: response_item wrapper
|
|
729
|
+
elif event_type == "response_item":
|
|
730
|
+
payload = data.get("payload", {})
|
|
731
|
+
payload_type = payload.get("type")
|
|
732
|
+
role = payload.get("role", "")
|
|
733
|
+
|
|
734
|
+
if payload_type == "message":
|
|
735
|
+
# Extract content from the message
|
|
736
|
+
content_list = payload.get("content", [])
|
|
737
|
+
text_parts = []
|
|
738
|
+
for item in content_list:
|
|
739
|
+
if isinstance(item, dict) and item.get("type") == "output_text":
|
|
740
|
+
text_parts.append(item.get("text", ""))
|
|
741
|
+
elif isinstance(item, dict) and item.get("type") == "input_text":
|
|
742
|
+
text_parts.append(item.get("text", ""))
|
|
743
|
+
elif isinstance(item, dict) and item.get("type") == "text":
|
|
744
|
+
text_parts.append(item.get("text", ""))
|
|
745
|
+
|
|
746
|
+
text = "".join(text_parts)
|
|
747
|
+
if text:
|
|
748
|
+
return Message(
|
|
749
|
+
uuid=payload.get("id", f"codex-resp-{line_num}"),
|
|
750
|
+
parent_uuid=None,
|
|
751
|
+
role=role if role else "assistant",
|
|
752
|
+
content=text,
|
|
753
|
+
timestamp=now,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
elif payload_type == "agent_message":
|
|
757
|
+
# Direct agent_message in payload
|
|
758
|
+
text = payload.get("text", "")
|
|
759
|
+
if text:
|
|
760
|
+
return Message(
|
|
761
|
+
uuid=payload.get("id", f"codex-agent-{line_num}"),
|
|
762
|
+
parent_uuid=None,
|
|
763
|
+
role="assistant",
|
|
764
|
+
content=text,
|
|
765
|
+
timestamp=now,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Exec mode: item.completed events
|
|
769
|
+
elif event_type == "item.completed":
|
|
770
|
+
item = data.get("item", {})
|
|
771
|
+
item_type = item.get("type")
|
|
772
|
+
|
|
773
|
+
if item_type == "agent_message":
|
|
774
|
+
text = item.get("text", "")
|
|
775
|
+
if text:
|
|
776
|
+
return Message(
|
|
777
|
+
uuid=item.get("id", f"codex-exec-{line_num}"),
|
|
778
|
+
parent_uuid=None,
|
|
779
|
+
role="assistant",
|
|
780
|
+
content=text,
|
|
781
|
+
timestamp=now,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
elif item_type == "reasoning":
|
|
785
|
+
# Reasoning/thinking block
|
|
786
|
+
text = item.get("text", "")
|
|
787
|
+
if text:
|
|
788
|
+
return Message(
|
|
789
|
+
uuid=item.get("id", f"codex-think-{line_num}"),
|
|
790
|
+
parent_uuid=None,
|
|
791
|
+
role="assistant",
|
|
792
|
+
content="", # Put reasoning in thinking field instead
|
|
793
|
+
timestamp=now,
|
|
794
|
+
thinking=text,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
elif item_type == "command_execution":
|
|
798
|
+
# Shell command execution
|
|
799
|
+
cmd = item.get("command", "")
|
|
800
|
+
output = item.get("aggregated_output", "")
|
|
801
|
+
exit_code = item.get("exit_code")
|
|
802
|
+
status = item.get("status", "")
|
|
803
|
+
if cmd:
|
|
804
|
+
content = f"Command: {cmd}\n"
|
|
805
|
+
if output:
|
|
806
|
+
content += f"Output:\n{output}\n"
|
|
807
|
+
if exit_code is not None:
|
|
808
|
+
content += f"Exit code: {exit_code}"
|
|
809
|
+
return Message(
|
|
810
|
+
uuid=item.get("id", f"codex-cmd-{line_num}"),
|
|
811
|
+
parent_uuid=None,
|
|
812
|
+
role="assistant",
|
|
813
|
+
content=content,
|
|
814
|
+
timestamp=now,
|
|
815
|
+
tool_uses=[{
|
|
816
|
+
"name": "command_execution",
|
|
817
|
+
"input": {"command": cmd, "status": status},
|
|
818
|
+
}],
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
elif item_type == "file_change":
|
|
822
|
+
# File modification
|
|
823
|
+
changes = item.get("changes", [])
|
|
824
|
+
if changes:
|
|
825
|
+
change_lines = []
|
|
826
|
+
for c in changes:
|
|
827
|
+
path = c.get("path", "")
|
|
828
|
+
kind = c.get("kind", "")
|
|
829
|
+
change_lines.append(f" {kind}: {path}")
|
|
830
|
+
content = "File changes:\n" + "\n".join(change_lines)
|
|
831
|
+
return Message(
|
|
832
|
+
uuid=item.get("id", f"codex-file-{line_num}"),
|
|
833
|
+
parent_uuid=None,
|
|
834
|
+
role="assistant",
|
|
835
|
+
content=content,
|
|
836
|
+
timestamp=now,
|
|
837
|
+
tool_uses=[{
|
|
838
|
+
"name": "file_change",
|
|
839
|
+
"input": {"changes": changes},
|
|
840
|
+
}],
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
return None
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
# =============================================================================
|
|
847
|
+
# Stop Hook Detection
|
|
848
|
+
# =============================================================================
|
|
849
|
+
|
|
850
|
+
# Marker format for Stop hook completion detection
|
|
851
|
+
STOP_HOOK_MARKER_PREFIX = "[worker-done:"
|
|
852
|
+
STOP_HOOK_MARKER_SUFFIX = "]"
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@dataclass
|
|
856
|
+
class StopHookEntry:
|
|
857
|
+
"""A stop_hook_summary entry from the JSONL."""
|
|
858
|
+
timestamp: datetime
|
|
859
|
+
marker_id: Optional[str] # The session ID from the marker, if found
|
|
860
|
+
hook_count: int
|
|
861
|
+
commands: list[str] # The hook commands that ran
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def extract_stop_hook_marker(command: str) -> Optional[str]:
|
|
865
|
+
"""
|
|
866
|
+
Extract the marker ID from a Stop hook command.
|
|
867
|
+
|
|
868
|
+
The marker format is: echo [worker-done:SESSION_ID]
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
command: The hook command string
|
|
872
|
+
|
|
873
|
+
Returns:
|
|
874
|
+
The session/marker ID if found, None otherwise
|
|
875
|
+
"""
|
|
876
|
+
start = command.find(STOP_HOOK_MARKER_PREFIX)
|
|
877
|
+
if start == -1:
|
|
878
|
+
return None
|
|
879
|
+
start += len(STOP_HOOK_MARKER_PREFIX)
|
|
880
|
+
end = command.find(STOP_HOOK_MARKER_SUFFIX, start)
|
|
881
|
+
if end == -1:
|
|
882
|
+
return None
|
|
883
|
+
return command[start:end]
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def parse_stop_hook_entries(jsonl_path: Path) -> list[StopHookEntry]:
|
|
887
|
+
"""
|
|
888
|
+
Parse all stop_hook_summary entries from a JSONL file.
|
|
889
|
+
|
|
890
|
+
Stop hook summaries are logged with:
|
|
891
|
+
- type: "system"
|
|
892
|
+
- subtype: "stop_hook_summary"
|
|
893
|
+
- hookInfos: list of {command: "..."}
|
|
894
|
+
- timestamp: ISO timestamp
|
|
895
|
+
|
|
896
|
+
Args:
|
|
897
|
+
jsonl_path: Path to the session JSONL file
|
|
898
|
+
|
|
899
|
+
Returns:
|
|
900
|
+
List of StopHookEntry objects, ordered by timestamp
|
|
901
|
+
"""
|
|
902
|
+
entries = []
|
|
903
|
+
|
|
904
|
+
try:
|
|
905
|
+
with open(jsonl_path, "r") as f:
|
|
906
|
+
for line in f:
|
|
907
|
+
line = line.strip()
|
|
908
|
+
if not line:
|
|
909
|
+
continue
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
entry = json.loads(line)
|
|
913
|
+
except json.JSONDecodeError:
|
|
914
|
+
continue
|
|
915
|
+
|
|
916
|
+
# Look for stop_hook_summary entries
|
|
917
|
+
if entry.get("type") != "system":
|
|
918
|
+
continue
|
|
919
|
+
if entry.get("subtype") != "stop_hook_summary":
|
|
920
|
+
continue
|
|
921
|
+
|
|
922
|
+
ts = parse_timestamp(entry)
|
|
923
|
+
|
|
924
|
+
# Extract commands from hookInfos
|
|
925
|
+
hook_infos = entry.get("hookInfos", [])
|
|
926
|
+
commands = [h.get("command", "") for h in hook_infos if h.get("command")]
|
|
927
|
+
|
|
928
|
+
# Try to find marker in any command
|
|
929
|
+
marker_id = None
|
|
930
|
+
for cmd in commands:
|
|
931
|
+
marker_id = extract_stop_hook_marker(cmd)
|
|
932
|
+
if marker_id:
|
|
933
|
+
break
|
|
934
|
+
|
|
935
|
+
entries.append(StopHookEntry(
|
|
936
|
+
timestamp=ts,
|
|
937
|
+
marker_id=marker_id,
|
|
938
|
+
hook_count=entry.get("hookCount", len(commands)),
|
|
939
|
+
commands=commands,
|
|
940
|
+
))
|
|
941
|
+
|
|
942
|
+
except FileNotFoundError:
|
|
943
|
+
return []
|
|
944
|
+
|
|
945
|
+
return sorted(entries, key=lambda e: e.timestamp)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def get_last_stop_hook_for_session(
|
|
949
|
+
jsonl_path: Path,
|
|
950
|
+
session_id: str,
|
|
951
|
+
) -> Optional[StopHookEntry]:
|
|
952
|
+
"""
|
|
953
|
+
Find the most recent stop_hook_summary entry for a specific session.
|
|
954
|
+
|
|
955
|
+
Args:
|
|
956
|
+
jsonl_path: Path to the session JSONL file
|
|
957
|
+
session_id: The session ID to look for in markers
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
The most recent StopHookEntry matching the session ID, or None
|
|
961
|
+
"""
|
|
962
|
+
entries = parse_stop_hook_entries(jsonl_path)
|
|
963
|
+
for entry in reversed(entries):
|
|
964
|
+
if entry.marker_id == session_id:
|
|
965
|
+
return entry
|
|
966
|
+
return None
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def is_session_stopped(
|
|
970
|
+
jsonl_path: Path,
|
|
971
|
+
session_id: str,
|
|
972
|
+
) -> bool:
|
|
973
|
+
"""
|
|
974
|
+
Check if a session has stopped (completed work) based on Stop hook detection.
|
|
975
|
+
|
|
976
|
+
A session is considered stopped if:
|
|
977
|
+
1. There is a stop_hook_summary entry with the session's marker
|
|
978
|
+
2. That entry is the last meaningful entry in the JSONL (no user/assistant
|
|
979
|
+
messages with content exist after it)
|
|
980
|
+
|
|
981
|
+
This provides reliable completion detection without relying on idle time
|
|
982
|
+
or explicit TASK_COMPLETE markers.
|
|
983
|
+
|
|
984
|
+
Args:
|
|
985
|
+
jsonl_path: Path to the session JSONL file
|
|
986
|
+
session_id: The session ID to check
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
True if the session has stopped, False if still working or no data
|
|
990
|
+
"""
|
|
991
|
+
# Parse file once, collecting stop hooks and message timestamps
|
|
992
|
+
last_stop_hook_ts: Optional[datetime] = None
|
|
993
|
+
last_message_ts: Optional[datetime] = None
|
|
994
|
+
|
|
995
|
+
try:
|
|
996
|
+
with open(jsonl_path, "r") as f:
|
|
997
|
+
for line in f:
|
|
998
|
+
line = line.strip()
|
|
999
|
+
if not line:
|
|
1000
|
+
continue
|
|
1001
|
+
|
|
1002
|
+
try:
|
|
1003
|
+
entry = json.loads(line)
|
|
1004
|
+
except json.JSONDecodeError:
|
|
1005
|
+
continue
|
|
1006
|
+
|
|
1007
|
+
# Check for stop_hook_summary entries
|
|
1008
|
+
if entry.get("type") == "system" and entry.get("subtype") == "stop_hook_summary":
|
|
1009
|
+
# Check if this stop hook matches our session
|
|
1010
|
+
hook_infos = entry.get("hookInfos", [])
|
|
1011
|
+
for h in hook_infos:
|
|
1012
|
+
cmd = h.get("command", "")
|
|
1013
|
+
marker_id = extract_stop_hook_marker(cmd)
|
|
1014
|
+
if marker_id == session_id:
|
|
1015
|
+
ts = parse_timestamp(entry)
|
|
1016
|
+
# Track the latest stop hook for this session
|
|
1017
|
+
if last_stop_hook_ts is None or ts > last_stop_hook_ts:
|
|
1018
|
+
last_stop_hook_ts = ts
|
|
1019
|
+
break
|
|
1020
|
+
continue
|
|
1021
|
+
|
|
1022
|
+
# Check for user/assistant messages with content
|
|
1023
|
+
entry_type = entry.get("type", "")
|
|
1024
|
+
if entry_type not in ("user", "assistant"):
|
|
1025
|
+
continue
|
|
1026
|
+
|
|
1027
|
+
# Check if this message has actual content
|
|
1028
|
+
message = entry.get("message", {})
|
|
1029
|
+
content = message.get("content", "")
|
|
1030
|
+
if isinstance(content, list):
|
|
1031
|
+
has_text = any(
|
|
1032
|
+
c.get("type") == "text" and c.get("text")
|
|
1033
|
+
for c in content
|
|
1034
|
+
if isinstance(c, dict)
|
|
1035
|
+
)
|
|
1036
|
+
if not has_text:
|
|
1037
|
+
continue
|
|
1038
|
+
elif not content:
|
|
1039
|
+
continue
|
|
1040
|
+
|
|
1041
|
+
msg_ts = parse_timestamp(entry)
|
|
1042
|
+
# Track the latest message timestamp
|
|
1043
|
+
if last_message_ts is None or msg_ts > last_message_ts:
|
|
1044
|
+
last_message_ts = msg_ts
|
|
1045
|
+
|
|
1046
|
+
except FileNotFoundError:
|
|
1047
|
+
return False
|
|
1048
|
+
|
|
1049
|
+
# No stop hook found for this session
|
|
1050
|
+
if last_stop_hook_ts is None:
|
|
1051
|
+
return False
|
|
1052
|
+
|
|
1053
|
+
# Check if any message exists after the stop hook
|
|
1054
|
+
if last_message_ts is not None and last_message_ts > last_stop_hook_ts:
|
|
1055
|
+
return False
|
|
1056
|
+
|
|
1057
|
+
# Stop hook fired and no messages after it - session is stopped
|
|
1058
|
+
return True
|