session-catchup.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. #!/usr/bin/env python3
  2. """
  3. Session Catchup Script for planning-with-files
  4. Analyzes the previous session to find unsynced context after the last
  5. planning file update. Designed to run on SessionStart.
  6. Usage: python3 session-catchup.py [project-path]
  7. """
  8. import json
  9. import sys
  10. import os
  11. from pathlib import Path
  12. from typing import Any, Dict, Iterable, List, Optional, Tuple
  13. try:
  14. import orjson
  15. except ImportError:
  16. orjson = None
  17. PLANNING_FILES = ['task_plan.md', 'progress.md', 'findings.md']
  18. MIN_SESSION_BYTES = 5000
  19. def json_loads(line: str) -> Optional[Dict[str, Any]]:
  20. """Prefer optional orjson while keeping the hook dependency-free."""
  21. try:
  22. if orjson is not None:
  23. data = orjson.loads(line)
  24. else:
  25. data = json.loads(line)
  26. except (ValueError, TypeError, UnicodeDecodeError):
  27. return None
  28. return data if isinstance(data, dict) else None
  29. def normalize_for_compare(path_value: str) -> str:
  30. expanded = os.path.expanduser(path_value)
  31. try:
  32. return str(Path(expanded).resolve())
  33. except (OSError, ValueError):
  34. return os.path.abspath(expanded)
  35. def normalize_path(project_path: str) -> str:
  36. """Normalize project path to match Claude Code's internal representation.
  37. Claude Code stores session directories using the Windows-native path
  38. (e.g., C:\\Users\\...) sanitized with separators replaced by dashes.
  39. Git Bash passes /c/Users/... which produces a DIFFERENT sanitized
  40. string. This function converts Git Bash paths to Windows paths first.
  41. """
  42. p = project_path
  43. # Git Bash / MSYS2: /c/Users/... -> C:/Users/...
  44. if len(p) >= 3 and p[0] == '/' and p[2] == '/':
  45. p = p[1].upper() + ':' + p[2:]
  46. # Resolve to absolute path to handle relative paths and symlinks
  47. try:
  48. resolved = str(Path(p).resolve())
  49. # On Windows, resolve() returns C:\Users\... which is what we want
  50. if os.name == 'nt' or '\\' in resolved:
  51. p = resolved
  52. except (OSError, ValueError):
  53. pass
  54. return p
  55. def get_claude_project_dir(project_path: str) -> Path:
  56. """Resolve Claude Code's project-specific session storage path."""
  57. normalized = normalize_path(project_path)
  58. # Claude Code's sanitization: replace path separators and : with -
  59. sanitized = normalized.replace('\\', '-').replace('/', '-').replace(':', '-')
  60. sanitized = sanitized.replace('_', '-')
  61. # Strip leading dash if present (Unix absolute paths start with /)
  62. if sanitized.startswith('-'):
  63. sanitized = sanitized[1:]
  64. return Path.home() / '.claude' / 'projects' / sanitized
  65. def get_sessions_sorted(project_dir: Path) -> List[Path]:
  66. """Get all session files sorted by modification time (newest first)."""
  67. sessions = list(project_dir.glob('*.jsonl'))
  68. main_sessions = [s for s in sessions if not s.name.startswith('agent-')]
  69. return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
  70. def safe_stat_mtime(path: Path) -> float:
  71. try:
  72. return path.stat().st_mtime
  73. except OSError:
  74. return 0.0
  75. def is_substantial_session(session: Path) -> bool:
  76. try:
  77. return session.stat().st_size > MIN_SESSION_BYTES
  78. except OSError:
  79. return False
  80. def read_codex_meta(session_file: Path) -> Optional[Dict[str, Any]]:
  81. """Read the first session_meta; later meta records may be copied parent context."""
  82. try:
  83. with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
  84. for line in f:
  85. data = json_loads(line)
  86. if not data or data.get('type') != 'session_meta':
  87. continue
  88. payload = data.get('payload')
  89. return payload if isinstance(payload, dict) else None
  90. except OSError:
  91. return None
  92. return None
  93. def codex_meta_cwd(meta: Dict[str, Any]) -> Optional[str]:
  94. cwd = meta.get('cwd')
  95. return cwd if isinstance(cwd, str) else None
  96. def find_current_codex_session(sessions: List[Path]) -> Optional[Path]:
  97. thread_id = os.getenv('CODEX_THREAD_ID', '').strip()
  98. if not thread_id:
  99. return None
  100. for session in sessions:
  101. if thread_id in session.name:
  102. return session
  103. return None
  104. def is_codex_project_session(session: Path, project_cmp: str) -> bool:
  105. if not is_substantial_session(session):
  106. return False
  107. meta = read_codex_meta(session)
  108. if not meta:
  109. return False
  110. source = meta.get('source')
  111. if isinstance(source, dict) and 'subagent' in source:
  112. return False
  113. cwd = codex_meta_cwd(meta)
  114. return bool(cwd and normalize_for_compare(cwd) == project_cmp)
  115. def get_codex_sessions(project_path: str) -> Iterable[Path]:
  116. sessions_dir = Path(os.path.expanduser(os.getenv('CODEX_SESSIONS_DIR', '~/.codex/sessions')))
  117. if not sessions_dir.exists():
  118. return
  119. project_cmp = normalize_for_compare(project_path)
  120. sessions = sorted(sessions_dir.rglob('rollout-*.jsonl'), key=safe_stat_mtime, reverse=True)
  121. current = find_current_codex_session(sessions)
  122. if current and is_codex_project_session(current, project_cmp):
  123. yield current
  124. for session in sessions:
  125. if session == current:
  126. continue
  127. if is_codex_project_session(session, project_cmp):
  128. yield session
  129. def get_session_candidates(project_path: str) -> Tuple[str, Iterable[Path]]:
  130. if '/.codex/' in Path(__file__).resolve().as_posix().lower():
  131. return 'codex', get_codex_sessions(project_path)
  132. claude_project_dir = get_claude_project_dir(project_path)
  133. if claude_project_dir.exists():
  134. return 'claude', get_sessions_sorted(claude_project_dir)
  135. return 'claude', []
  136. def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
  137. """Parse all messages from a session file, preserving order."""
  138. messages = []
  139. with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
  140. for line_num, line in enumerate(f):
  141. data = json_loads(line)
  142. if data is not None:
  143. data['_line_num'] = line_num
  144. messages.append(data)
  145. return messages
  146. def planning_file_from_path(path_value: Any) -> Optional[str]:
  147. if not isinstance(path_value, str):
  148. return None
  149. for pf in PLANNING_FILES:
  150. if path_value.endswith(pf):
  151. return pf
  152. return None
  153. def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
  154. matches = {pf for path in paths if (pf := planning_file_from_path(path))}
  155. for pf in PLANNING_FILES:
  156. if pf in matches:
  157. return pf
  158. return None
  159. def codex_planning_update(payload: Dict[str, Any]) -> Optional[str]:
  160. """Use Codex's structured apply_patch result instead of parsing tool text."""
  161. if payload.get('type') != 'patch_apply_end' or payload.get('success') is not True:
  162. return None
  163. changes = payload.get('changes')
  164. return planning_file_from_paths(changes.keys()) if isinstance(changes, dict) else None
  165. def find_last_planning_update(messages: List[Dict[str, Any]]) -> Tuple[int, Optional[str]]:
  166. """
  167. Find the last time a planning file was written/edited.
  168. Returns (line_number, filename) or (-1, None) if not found.
  169. """
  170. last_update_line = -1
  171. last_update_file = None
  172. for msg in messages:
  173. line_num = msg.get('_line_num')
  174. if not isinstance(line_num, int):
  175. continue
  176. msg_type = msg.get('type')
  177. if msg_type == 'assistant':
  178. content = msg.get('message', {}).get('content', [])
  179. if isinstance(content, list):
  180. for item in content:
  181. if item.get('type') == 'tool_use':
  182. tool_name = item.get('name', '')
  183. tool_input = item.get('input', {})
  184. if not isinstance(tool_input, dict):
  185. tool_input = {}
  186. if tool_name in ('Write', 'Edit'):
  187. planning_file = planning_file_from_path(tool_input.get('file_path', ''))
  188. if planning_file:
  189. last_update_line = line_num
  190. last_update_file = planning_file
  191. elif msg_type == 'event_msg':
  192. payload = msg.get('payload')
  193. if isinstance(payload, dict):
  194. planning_file = codex_planning_update(payload)
  195. if planning_file:
  196. last_update_line = line_num
  197. last_update_file = planning_file
  198. return last_update_line, last_update_file
  199. def text_content(content: Any) -> str:
  200. if isinstance(content, str):
  201. return content
  202. if not isinstance(content, list):
  203. return ''
  204. return '\n'.join(
  205. item.get('text', '')
  206. for item in content
  207. if isinstance(item, dict) and isinstance(item.get('text'), str)
  208. )
  209. def parse_codex_tool_args(payload: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
  210. raw_args = payload.get('arguments', payload.get('input', ''))
  211. if isinstance(raw_args, dict):
  212. return raw_args, json.dumps(raw_args, ensure_ascii=True)
  213. if not isinstance(raw_args, str):
  214. return {}, ''
  215. decoded = json_loads(raw_args)
  216. return (decoded, raw_args) if isinstance(decoded, dict) else ({}, raw_args)
  217. def summarize_codex_tool(payload: Dict[str, Any]) -> str:
  218. tool_name = payload.get('name', 'tool')
  219. tool_args, raw_args = parse_codex_tool_args(payload)
  220. if tool_name == 'exec_command':
  221. command = tool_args.get('cmd', raw_args)
  222. if isinstance(command, str):
  223. return f"exec_command: {command[:80]}"
  224. return str(tool_name)
  225. def extract_messages_after(messages: List[Dict[str, Any]], after_line: int) -> List[Dict[str, Any]]:
  226. """Extract conversation messages after a certain line number."""
  227. result = []
  228. for msg in messages:
  229. line_num = msg.get('_line_num')
  230. if not isinstance(line_num, int) or line_num <= after_line:
  231. continue
  232. msg_type = msg.get('type')
  233. is_meta = msg.get('isMeta', False)
  234. if msg_type == 'user' and not is_meta:
  235. content = text_content(msg.get('message', {}).get('content', ''))
  236. if content:
  237. if content.startswith(('<local-command', '<command-', '<task-notification')):
  238. continue
  239. if len(content) > 20:
  240. result.append({'role': 'user', 'content': content, 'line': line_num})
  241. elif msg_type == 'assistant':
  242. msg_content = msg.get('message', {}).get('content', '')
  243. text = text_content(msg_content)
  244. tool_uses = []
  245. if isinstance(msg_content, list):
  246. for item in msg_content:
  247. if isinstance(item, dict) and item.get('type') == 'tool_use':
  248. tool_name = item.get('name', '')
  249. tool_input = item.get('input', {})
  250. if not isinstance(tool_input, dict):
  251. tool_input = {}
  252. if tool_name == 'Edit':
  253. tool_uses.append(f"Edit: {tool_input.get('file_path', 'unknown')}")
  254. elif tool_name == 'Write':
  255. tool_uses.append(f"Write: {tool_input.get('file_path', 'unknown')}")
  256. elif tool_name == 'Bash':
  257. cmd = tool_input.get('command', '')[:80]
  258. tool_uses.append(f"Bash: {cmd}")
  259. else:
  260. tool_uses.append(f"{tool_name}")
  261. if text or tool_uses:
  262. result.append({
  263. 'role': 'assistant',
  264. 'content': text[:600] if text else '',
  265. 'tools': tool_uses,
  266. 'line': line_num
  267. })
  268. elif msg_type == 'response_item':
  269. payload = msg.get('payload')
  270. if not isinstance(payload, dict):
  271. continue
  272. payload_type = payload.get('type')
  273. if payload_type == 'message':
  274. role = payload.get('role')
  275. if role not in ('user', 'assistant'):
  276. continue
  277. content = text_content(payload.get('content'))
  278. if role == 'user':
  279. if content.startswith(('<local-command', '<command-', '<task-notification')):
  280. continue
  281. if len(content) > 20:
  282. result.append({'role': 'user', 'content': content, 'line': line_num})
  283. elif content:
  284. result.append({
  285. 'role': 'assistant',
  286. 'content': content[:600],
  287. 'tools': [],
  288. 'line': line_num
  289. })
  290. elif payload_type in ('function_call', 'custom_tool_call'):
  291. result.append({
  292. 'role': 'assistant',
  293. 'content': '',
  294. 'tools': [summarize_codex_tool(payload)],
  295. 'line': line_num
  296. })
  297. return result
  298. def main():
  299. project_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
  300. # Check if planning files exist (indicates active task)
  301. has_planning_files = any(
  302. Path(project_path, f).exists() for f in PLANNING_FILES
  303. )
  304. if not has_planning_files:
  305. # No planning files in this project; skip catchup to avoid noise.
  306. return
  307. runtime_name, sessions = get_session_candidates(project_path)
  308. # Find a substantial previous session
  309. target_session = None
  310. for session in sessions:
  311. if runtime_name == 'claude' and not is_substantial_session(session):
  312. continue
  313. target_session = session
  314. break
  315. if not target_session:
  316. return
  317. messages = parse_session_messages(target_session)
  318. last_update_line, last_update_file = find_last_planning_update(messages)
  319. # No planning updates in the target session; skip catchup output.
  320. if last_update_line < 0:
  321. return
  322. # Only output if there's unsynced content
  323. messages_after = extract_messages_after(messages, last_update_line)
  324. if not messages_after:
  325. return
  326. # Output catchup report
  327. print("\n[planning-with-files] SESSION CATCHUP DETECTED")
  328. print(f"Previous session: {target_session.stem}")
  329. print(f"Runtime: {runtime_name}")
  330. print(f"Last planning update: {last_update_file} at message #{last_update_line}")
  331. print(f"Unsynced messages: {len(messages_after)}")
  332. print("\n--- UNSYNCED CONTEXT ---")
  333. assistant_label = 'CODEX' if runtime_name == 'codex' else 'CLAUDE'
  334. for msg in messages_after[-15:]: # Last 15 messages
  335. if msg['role'] == 'user':
  336. print(f"USER: {msg['content'][:300]}")
  337. else:
  338. if msg.get('content'):
  339. print(f"{assistant_label}: {msg['content'][:300]}")
  340. if msg.get('tools'):
  341. print(f" Tools: {', '.join(msg['tools'][:4])}")
  342. print("\n--- RECOMMENDED ---")
  343. print("1. Run: git diff --stat")
  344. print("2. Read: task_plan.md, progress.md, findings.md")
  345. print("3. Update planning files based on above context")
  346. print("4. Continue with task")
  347. if __name__ == '__main__':
  348. main()