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.
Files changed (42) hide show
  1. claude_team_mcp/__init__.py +24 -0
  2. claude_team_mcp/__main__.py +8 -0
  3. claude_team_mcp/cli_backends/__init__.py +44 -0
  4. claude_team_mcp/cli_backends/base.py +132 -0
  5. claude_team_mcp/cli_backends/claude.py +110 -0
  6. claude_team_mcp/cli_backends/codex.py +110 -0
  7. claude_team_mcp/colors.py +108 -0
  8. claude_team_mcp/formatting.py +120 -0
  9. claude_team_mcp/idle_detection.py +488 -0
  10. claude_team_mcp/iterm_utils.py +1119 -0
  11. claude_team_mcp/names.py +427 -0
  12. claude_team_mcp/profile.py +364 -0
  13. claude_team_mcp/registry.py +426 -0
  14. claude_team_mcp/schemas/__init__.py +5 -0
  15. claude_team_mcp/schemas/codex.py +267 -0
  16. claude_team_mcp/server.py +390 -0
  17. claude_team_mcp/session_state.py +1058 -0
  18. claude_team_mcp/subprocess_cache.py +119 -0
  19. claude_team_mcp/tools/__init__.py +52 -0
  20. claude_team_mcp/tools/adopt_worker.py +122 -0
  21. claude_team_mcp/tools/annotate_worker.py +57 -0
  22. claude_team_mcp/tools/bd_help.py +42 -0
  23. claude_team_mcp/tools/check_idle_workers.py +98 -0
  24. claude_team_mcp/tools/close_workers.py +194 -0
  25. claude_team_mcp/tools/discover_workers.py +129 -0
  26. claude_team_mcp/tools/examine_worker.py +56 -0
  27. claude_team_mcp/tools/list_workers.py +76 -0
  28. claude_team_mcp/tools/list_worktrees.py +106 -0
  29. claude_team_mcp/tools/message_workers.py +311 -0
  30. claude_team_mcp/tools/read_worker_logs.py +158 -0
  31. claude_team_mcp/tools/spawn_workers.py +634 -0
  32. claude_team_mcp/tools/wait_idle_workers.py +148 -0
  33. claude_team_mcp/utils/__init__.py +17 -0
  34. claude_team_mcp/utils/constants.py +87 -0
  35. claude_team_mcp/utils/errors.py +87 -0
  36. claude_team_mcp/utils/worktree_detection.py +79 -0
  37. claude_team_mcp/worker_prompt.py +350 -0
  38. claude_team_mcp/worktree.py +532 -0
  39. claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
  40. claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
  41. claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
  42. 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