synapse-cli-agent 0.1.13__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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,629 @@
1
+ """Read-only discovery of local Codex sessions.
2
+
3
+ This module intentionally discovers metadata only. It never resumes Codex,
4
+ parses a full conversation, creates a Synapse session, or writes a LangGraph
5
+ checkpoint. Importing is a later phase with separate history and checkpoint
6
+ compatibility requirements.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import sqlite3
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.parse import quote
21
+
22
+ import zstandard
23
+
24
+ DEFAULT_LIMIT = 50
25
+ MAX_LIMIT = 200
26
+ MAX_DB_ROWS = 5_000
27
+ MAX_SCAN_FILES = 500
28
+ MAX_ROLLOUT_BYTES = 32 * 1024 * 1024
29
+ MAX_HEAD_BYTES = 2 * 1024 * 1024
30
+ MAX_HEAD_RECORDS = 50
31
+ MAX_HEADER_LINE_BYTES = 64 * 1024
32
+ MAX_TITLE_CHARS = 120
33
+
34
+ _STATE_DB_RE = re.compile(r"state_(\d+)\.sqlite\Z")
35
+ _ROLLOUT_RE = re.compile(
36
+ r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-"
37
+ r"([0-9a-fA-F-]{36})\.jsonl(?:\.zst)?\Z"
38
+ )
39
+ _ALLOWED_SOURCES = frozenset(
40
+ {"cli", "vscode", "exec", "mcp", '{"custom":"atlas"}', '{"custom":"chatgpt"}'}
41
+ )
42
+ _SUBAGENT_SOURCE_PREFIX = '{"subagent":'
43
+ _INTERNAL_TITLE_MARKERS = (
44
+ "<environment_context>",
45
+ "<user_instructions>",
46
+ "<developer_instructions>",
47
+ "# agents.md instructions",
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CodexSession:
53
+ """A validated, metadata-only reference to one Codex rollout."""
54
+
55
+ native_id: str
56
+ title: str
57
+ cwd: Path
58
+ updated_at: datetime
59
+ source: str
60
+ rollout_path: Path
61
+ fingerprint: str
62
+ discovery: str
63
+ warnings: tuple[str, ...] = ()
64
+
65
+ def to_dict(self) -> dict[str, str | list[str]]:
66
+ return {
67
+ "native_id": self.native_id,
68
+ "title": self.title,
69
+ "cwd": str(self.cwd),
70
+ "updated_at": self.updated_at.isoformat(),
71
+ "source": self.source,
72
+ "rollout_path": str(self.rollout_path),
73
+ "fingerprint": self.fingerprint,
74
+ "discovery": self.discovery,
75
+ "warnings": list(self.warnings),
76
+ }
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class CodexScanResult:
81
+ """Scanner result with non-fatal diagnostic warnings."""
82
+
83
+ codex_home: Path
84
+ sessions: tuple[CodexSession, ...]
85
+ warnings: tuple[str, ...]
86
+ discovery: str
87
+
88
+
89
+ class CodexSessionScanner:
90
+ """Discover sessions matching one workspace without mutating Codex state."""
91
+
92
+ def __init__(self, codex_home: Path | str | None = None) -> None:
93
+ self._codex_home = _resolve_codex_home(codex_home)
94
+
95
+ @property
96
+ def codex_home(self) -> Path:
97
+ return self._codex_home
98
+
99
+ def scan(
100
+ self,
101
+ workspace: Path | str | None = None,
102
+ *,
103
+ limit: int = DEFAULT_LIMIT,
104
+ include_rollout_fallback: bool = False,
105
+ ) -> CodexScanResult:
106
+ """Return recent readable sessions, optionally scoped to one workspace.
107
+
108
+ The highest numbered ``state_N.sqlite`` is preferred. A missing or
109
+ incompatible state DB falls back to bounded ``sessions/**/*.jsonl``
110
+ header inspection. Compressed rollouts use a bounded zstd header reader.
111
+ """
112
+ workspace_input = Path(workspace).expanduser() if workspace is not None else None
113
+ workspace_path = _canonical_path(workspace_input) if workspace_input is not None else None
114
+ workspace_spellings = (
115
+ tuple(dict.fromkeys((str(workspace_path), str(workspace_input))))
116
+ if workspace_path is not None and workspace_input is not None
117
+ else ()
118
+ )
119
+ limit = max(1, min(int(limit), MAX_LIMIT))
120
+ warnings: list[str] = []
121
+
122
+ if not self._codex_home.is_dir():
123
+ return CodexScanResult(
124
+ codex_home=self._codex_home,
125
+ sessions=(),
126
+ warnings=(f"Codex home not found: {self._codex_home}",),
127
+ discovery="none",
128
+ )
129
+
130
+ sessions_root = self._codex_home / "sessions"
131
+ if sessions_root.is_symlink():
132
+ return CodexScanResult(
133
+ codex_home=self._codex_home,
134
+ sessions=(),
135
+ warnings=("Codex sessions root is a symlink and was ignored",),
136
+ discovery="none",
137
+ )
138
+
139
+ state_db = _latest_state_db(self._codex_home)
140
+ if state_db is not None:
141
+ try:
142
+ sessions, state_warnings = _scan_state_db(
143
+ state_db,
144
+ self._codex_home,
145
+ workspace_path,
146
+ workspace_spellings=workspace_spellings,
147
+ limit=limit,
148
+ )
149
+ warnings.extend(state_warnings)
150
+ except _UnsupportedStateDb as exc:
151
+ warnings.append(f"state DB ignored: {exc}")
152
+ except sqlite3.Error as exc:
153
+ warnings.append(f"state DB read failed: {type(exc).__name__}")
154
+ else:
155
+ if include_rollout_fallback:
156
+ fallback_sessions, fallback_warnings = _scan_rollout_headers(
157
+ self._codex_home,
158
+ workspace_path,
159
+ limit=limit,
160
+ )
161
+ warnings.extend(fallback_warnings)
162
+ known_ids = {session.native_id for session in sessions}
163
+ sessions.extend(
164
+ session
165
+ for session in fallback_sessions
166
+ if session.native_id not in known_ids
167
+ )
168
+ sessions.sort(key=lambda session: session.updated_at, reverse=True)
169
+ sessions = sessions[:limit]
170
+ return CodexScanResult(
171
+ codex_home=self._codex_home,
172
+ sessions=tuple(sessions),
173
+ warnings=tuple(warnings),
174
+ discovery="state_db",
175
+ )
176
+
177
+ sessions, fallback_warnings = _scan_rollout_headers(
178
+ self._codex_home, workspace_path, limit=limit
179
+ )
180
+ warnings.extend(fallback_warnings)
181
+ return CodexScanResult(
182
+ codex_home=self._codex_home,
183
+ sessions=tuple(sessions),
184
+ warnings=tuple(warnings),
185
+ discovery="rollout_headers",
186
+ )
187
+
188
+ def inspect(
189
+ self,
190
+ native_id: str,
191
+ *,
192
+ workspace: Path | str | None = None,
193
+ limit: int = MAX_LIMIT,
194
+ include_rollout_fallback: bool = False,
195
+ ) -> CodexSession | None:
196
+ """Look up one native session id, optionally scoped to one workspace."""
197
+ sessions = self.scan(
198
+ workspace,
199
+ limit=limit,
200
+ include_rollout_fallback=include_rollout_fallback,
201
+ ).sessions
202
+ return next((session for session in sessions if session.native_id == native_id), None)
203
+
204
+
205
+ class _UnsupportedStateDb(ValueError):
206
+ pass
207
+
208
+
209
+ def _resolve_codex_home(value: Path | str | None) -> Path:
210
+ raw = value or os.environ.get("CODEX_HOME") or (Path.home() / ".codex")
211
+ return _canonical_path(Path(raw).expanduser())
212
+
213
+
214
+ def _canonical_path(path: Path) -> Path:
215
+ raw = str(path)
216
+ if os.name == "nt":
217
+ if raw.startswith("\\\\?\\UNC\\"):
218
+ raw = "\\\\" + raw[8:]
219
+ elif raw.startswith("\\\\?\\"):
220
+ raw = raw[4:]
221
+ normalized = Path(raw)
222
+ try:
223
+ return normalized.resolve(strict=False)
224
+ except OSError:
225
+ return Path(os.path.abspath(normalized))
226
+
227
+
228
+ def _same_path(left: Path, right: Path) -> bool:
229
+ return os.path.normcase(str(_canonical_path(left))) == os.path.normcase(
230
+ str(_canonical_path(right))
231
+ )
232
+
233
+
234
+ def _is_under(path: Path, root: Path) -> bool:
235
+ try:
236
+ common = os.path.commonpath((str(_canonical_path(path)), str(_canonical_path(root))))
237
+ except ValueError:
238
+ return False
239
+ return _same_path(Path(common), root)
240
+
241
+
242
+ def _latest_state_db(codex_home: Path) -> Path | None:
243
+ candidates: list[tuple[int, Path]] = []
244
+ try:
245
+ children = list(codex_home.iterdir())
246
+ except OSError:
247
+ return None
248
+ for path in children:
249
+ match = _STATE_DB_RE.fullmatch(path.name)
250
+ if not match:
251
+ continue
252
+ try:
253
+ if path.is_file() and not path.is_symlink():
254
+ candidates.append((int(match.group(1)), path))
255
+ except OSError:
256
+ continue
257
+ return max(candidates, default=(0, None), key=lambda item: item[0])[1]
258
+
259
+
260
+ def _readonly_connection(path: Path) -> sqlite3.Connection:
261
+ uri = f"file:{quote(str(path).replace(os.sep, '/'))}?mode=ro"
262
+ connection = sqlite3.connect(uri, uri=True, timeout=1.0)
263
+ connection.execute("PRAGMA query_only = ON")
264
+ return connection
265
+
266
+
267
+ def _scan_state_db(
268
+ state_db: Path,
269
+ codex_home: Path,
270
+ workspace: Path | None,
271
+ *,
272
+ workspace_spellings: tuple[str, ...],
273
+ limit: int,
274
+ ) -> tuple[list[CodexSession], list[str]]:
275
+ with _readonly_connection(state_db) as connection:
276
+ columns = {
277
+ str(row[1])
278
+ for row in connection.execute("PRAGMA table_info(threads)").fetchall()
279
+ }
280
+ required = {"id", "rollout_path", "source", "cwd", "archived"}
281
+ if not required.issubset(columns):
282
+ raise _UnsupportedStateDb("threads table has an unsupported schema")
283
+ updated_column = "updated_at_ms" if "updated_at_ms" in columns else "updated_at"
284
+ if updated_column not in columns:
285
+ raise _UnsupportedStateDb("threads table has no update timestamp")
286
+
287
+ title_column = "title" if "title" in columns else "''"
288
+ first_user_column = "first_user_message" if "first_user_message" in columns else "''"
289
+ workspace_filter = ""
290
+ query_params: tuple[object, ...] = ()
291
+ if workspace_spellings:
292
+ placeholders = ", ".join("?" for _ in workspace_spellings)
293
+ workspace_filter = f"\n AND cwd IN ({placeholders})"
294
+ query_params = workspace_spellings
295
+ query = f"""
296
+ SELECT id, rollout_path, {updated_column}, source, cwd,
297
+ {title_column}, {first_user_column}
298
+ FROM threads
299
+ WHERE typeof(id) = 'text'
300
+ AND typeof(rollout_path) = 'text'
301
+ AND typeof({updated_column}) = 'integer'
302
+ AND typeof(source) = 'text'
303
+ AND typeof(cwd) = 'text'
304
+ AND typeof(archived) = 'integer'
305
+ AND archived = 0{workspace_filter}
306
+ ORDER BY {updated_column} DESC, id ASC
307
+ LIMIT ?
308
+ """
309
+ rows = connection.execute(query, (*query_params, MAX_DB_ROWS)).fetchall()
310
+
311
+ sessions: list[CodexSession] = []
312
+ warnings: list[str] = []
313
+ if len(rows) == MAX_DB_ROWS:
314
+ warnings.append("state DB scan truncated at row limit")
315
+ for row in rows:
316
+ if len(sessions) >= limit:
317
+ break
318
+ native_id, rollout_raw, updated_raw, source, cwd_raw, title, first_user = row
319
+ if not _valid_native_id(native_id) or not _allowed_source(source):
320
+ continue
321
+ if not _text_within(cwd_raw, 16 * 1024) or not _text_within(rollout_raw, 16 * 1024):
322
+ continue
323
+ raw_cwd = Path(cwd_raw)
324
+ if not raw_cwd.is_absolute():
325
+ warnings.append(f"skipped nonabsolute workspace path for {native_id}")
326
+ continue
327
+ cwd = _canonical_path(raw_cwd)
328
+ if workspace is not None and not _same_path(cwd, workspace):
329
+ continue
330
+ rollout_path = _validated_rollout_path(codex_home, rollout_raw, native_id)
331
+ if rollout_path is None:
332
+ warnings.append(f"skipped invalid rollout path for {native_id}")
333
+ continue
334
+ updated_at = _timestamp_from_epoch(updated_raw)
335
+ if updated_at is None:
336
+ continue
337
+ try:
338
+ fingerprint = _fingerprint(native_id, rollout_path)
339
+ except OSError:
340
+ warnings.append(f"skipped unavailable rollout for {native_id}")
341
+ continue
342
+ state_title = _safe_title(title)
343
+ first_user_title = _safe_title(first_user)
344
+ session = CodexSession(
345
+ native_id=native_id,
346
+ title=first_user_title or state_title or "(untitled Codex session)",
347
+ cwd=cwd,
348
+ updated_at=updated_at,
349
+ source=_source_label(source),
350
+ rollout_path=rollout_path,
351
+ fingerprint=fingerprint,
352
+ discovery="state_db",
353
+ warnings=(),
354
+ )
355
+ sessions.append(session)
356
+ return sessions, warnings
357
+
358
+
359
+ def _scan_rollout_headers(
360
+ codex_home: Path, workspace: Path | None, *, limit: int
361
+ ) -> tuple[list[CodexSession], list[str]]:
362
+ sessions_root = codex_home / "sessions"
363
+ warnings: list[str] = []
364
+ if not sessions_root.is_dir():
365
+ return [], warnings
366
+
367
+ candidates: list[Path] = []
368
+ for directory, dirs, names in os.walk(sessions_root, followlinks=False):
369
+ dirs.sort(reverse=True)
370
+ for name in sorted(names, reverse=True):
371
+ if len(candidates) >= MAX_SCAN_FILES:
372
+ warnings.append("rollout scan truncated at file limit")
373
+ break
374
+ path = Path(directory) / name
375
+ if _ROLLOUT_RE.fullmatch(name):
376
+ candidates.append(path)
377
+ if len(candidates) >= MAX_SCAN_FILES:
378
+ break
379
+
380
+ sessions: list[CodexSession] = []
381
+ for path in sorted(candidates, key=_mtime_ns, reverse=True):
382
+ if len(sessions) >= limit:
383
+ break
384
+ match = _ROLLOUT_RE.fullmatch(path.name)
385
+ if match is None:
386
+ continue
387
+ native_id = match.group(1).lower()
388
+ rollout_path = _validated_rollout_path(codex_home, str(path), native_id)
389
+ if rollout_path is None:
390
+ warnings.append(f"skipped invalid rollout path for {native_id}")
391
+ continue
392
+ try:
393
+ metadata = _read_rollout_head(rollout_path)
394
+ except (OSError, ValueError) as exc:
395
+ warnings.append(f"skipped rollout {native_id}: {exc}")
396
+ continue
397
+ if (
398
+ metadata is None
399
+ or (workspace is not None and not _same_path(metadata["cwd"], workspace))
400
+ or metadata["source"] == "unknown"
401
+ ):
402
+ continue
403
+ try:
404
+ fingerprint = _fingerprint(native_id, rollout_path)
405
+ except OSError:
406
+ warnings.append(f"skipped unavailable rollout for {native_id}")
407
+ continue
408
+ updated_at = datetime.fromtimestamp(_mtime_ns(rollout_path) / 1_000_000_000, tz=UTC)
409
+ sessions.append(
410
+ CodexSession(
411
+ native_id=native_id,
412
+ title=metadata["title"] or "(untitled Codex session)",
413
+ cwd=metadata["cwd"],
414
+ updated_at=updated_at,
415
+ source=metadata["source"],
416
+ rollout_path=rollout_path,
417
+ fingerprint=fingerprint,
418
+ discovery="rollout_headers",
419
+ warnings=(),
420
+ )
421
+ )
422
+ return sessions, warnings
423
+
424
+
425
+ def _read_rollout_head(path: Path) -> dict[str, Any] | None:
426
+ try:
427
+ size = path.stat().st_size
428
+ except OSError as exc:
429
+ raise ValueError("file metadata unavailable") from exc
430
+ if size > MAX_ROLLOUT_BYTES:
431
+ raise ValueError("file exceeds size limit")
432
+
433
+ metadata: dict[str, Any] = {"cwd": None, "source": "unknown", "title": ""}
434
+ try:
435
+ if path.suffix == ".zst":
436
+ with path.open("rb") as raw:
437
+ with zstandard.ZstdDecompressor().stream_reader(raw) as compressed:
438
+ records = _rollout_head_lines(compressed)
439
+ _read_rollout_header_records(records, metadata)
440
+ else:
441
+ with path.open("rb") as raw:
442
+ _read_rollout_header_records(_rollout_head_lines(raw), metadata)
443
+ except zstandard.ZstdError as exc:
444
+ raise ValueError("invalid zstd data") from exc
445
+ return metadata if isinstance(metadata["cwd"], Path) else None
446
+
447
+
448
+ def _rollout_head_lines(stream: Any):
449
+ total = 0
450
+ pending = b""
451
+ records = 0
452
+ discarding = False
453
+ while records < MAX_HEAD_RECORDS:
454
+ chunk = stream.read(MAX_HEADER_LINE_BYTES)
455
+ if not chunk:
456
+ break
457
+ total += len(chunk)
458
+ if total > MAX_HEAD_BYTES:
459
+ raise ValueError("header exceeds size limit")
460
+ if discarding:
461
+ newline = chunk.find(b"\n")
462
+ if newline < 0:
463
+ continue
464
+ records += 1
465
+ yield None
466
+ discarding = False
467
+ chunk = chunk[newline + 1 :]
468
+ pending += chunk
469
+ while b"\n" in pending and records < MAX_HEAD_RECORDS:
470
+ line, pending = pending.split(b"\n", 1)
471
+ records += 1
472
+ if len(line) > MAX_HEADER_LINE_BYTES:
473
+ yield None
474
+ else:
475
+ yield line.decode("utf-8", errors="replace")
476
+ if len(pending) > MAX_HEADER_LINE_BYTES:
477
+ pending = b""
478
+ discarding = True
479
+ if pending and not discarding and records < MAX_HEAD_RECORDS:
480
+ yield pending.decode("utf-8", errors="replace")
481
+
482
+
483
+ def _read_rollout_header_records(records: Any, metadata: dict[str, Any]) -> None:
484
+ for line in records:
485
+ if line is None:
486
+ continue
487
+ try:
488
+ record = json.loads(line)
489
+ except json.JSONDecodeError:
490
+ continue
491
+ if not isinstance(record, dict):
492
+ continue
493
+ record_type = record.get("type")
494
+ payload = record.get("payload")
495
+ if record_type == "session_meta" and isinstance(payload, dict):
496
+ cwd = payload.get("cwd")
497
+ if isinstance(cwd, str) and _text_within(cwd, 16 * 1024):
498
+ raw_cwd = Path(cwd)
499
+ if raw_cwd.is_absolute():
500
+ metadata["cwd"] = _canonical_path(raw_cwd)
501
+ source = payload.get("source")
502
+ if isinstance(source, str) and _allowed_source(source):
503
+ metadata["source"] = _source_label(source)
504
+ elif isinstance(source, dict) and _is_thread_spawn_source(source):
505
+ metadata["source"] = "subagent"
506
+ title = payload.get("title")
507
+ if isinstance(title, str):
508
+ metadata["title"] = _safe_title(title)
509
+ elif record_type == "event_msg" and isinstance(payload, dict):
510
+ if payload.get("type") == "user_message" and not metadata["title"]:
511
+ message = payload.get("message")
512
+ if isinstance(message, str):
513
+ metadata["title"] = _safe_title(message)
514
+ if (
515
+ isinstance(metadata["cwd"], Path)
516
+ and metadata["source"] != "unknown"
517
+ and metadata["title"]
518
+ ):
519
+ return
520
+
521
+
522
+ def _validated_rollout_path(codex_home: Path, raw_path: str, native_id: str) -> Path | None:
523
+ candidate = Path(raw_path)
524
+ if not candidate.is_absolute():
525
+ candidate = codex_home / candidate
526
+ try:
527
+ resolved = candidate.resolve(strict=True)
528
+ sessions_root = (codex_home / "sessions").resolve(strict=True)
529
+ except OSError:
530
+ return None
531
+ if not _is_under(resolved, sessions_root) or not resolved.is_file():
532
+ return None
533
+ match = _ROLLOUT_RE.fullmatch(resolved.name)
534
+ if match is None or match.group(1).lower() != native_id.lower():
535
+ return None
536
+ return resolved
537
+
538
+
539
+ def _valid_native_id(value: object) -> bool:
540
+ if not isinstance(value, str) or len(value) != 36:
541
+ return False
542
+ try:
543
+ import uuid
544
+
545
+ uuid.UUID(value)
546
+ except (ValueError, AttributeError):
547
+ return False
548
+ return True
549
+
550
+
551
+ def _is_thread_spawn_source(value: object) -> bool:
552
+ if not isinstance(value, dict):
553
+ return False
554
+ subagent = value.get("subagent")
555
+ if not isinstance(subagent, dict):
556
+ return False
557
+ thread_spawn = subagent.get("thread_spawn")
558
+ if not isinstance(thread_spawn, dict):
559
+ return False
560
+ parent_id = thread_spawn.get("parent_thread_id")
561
+ depth = thread_spawn.get("depth")
562
+ return _valid_native_id(parent_id) and isinstance(depth, int) and depth >= 1
563
+
564
+
565
+ def _allowed_source(value: object) -> bool:
566
+ if not isinstance(value, str) or not _text_within(value, 16 * 1024):
567
+ return False
568
+ normalized = value.replace(" ", "")
569
+ if normalized in _ALLOWED_SOURCES:
570
+ return True
571
+ if not normalized.startswith(_SUBAGENT_SOURCE_PREFIX):
572
+ return False
573
+ try:
574
+ return _is_thread_spawn_source(json.loads(value))
575
+ except json.JSONDecodeError:
576
+ return False
577
+
578
+
579
+ def _source_label(value: str) -> str:
580
+ normalized = value.replace(" ", "")
581
+ if normalized.startswith(_SUBAGENT_SOURCE_PREFIX):
582
+ return "subagent"
583
+ if normalized == "cli":
584
+ return "cli"
585
+ if normalized == "vscode":
586
+ return "vscode"
587
+ if normalized == '{"custom":"atlas"}':
588
+ return "atlas"
589
+ if normalized == '{"custom":"chatgpt"}':
590
+ return "chatgpt"
591
+ return "unknown"
592
+
593
+
594
+ def _text_within(value: object, maximum: int) -> bool:
595
+ return isinstance(value, str) and len(value.encode("utf-8", errors="ignore")) <= maximum
596
+
597
+
598
+ def _safe_title(value: object) -> str:
599
+ if not isinstance(value, str):
600
+ return ""
601
+ text = " ".join(value.replace("\x00", " ").split())
602
+ if not text or any(marker in text.casefold() for marker in _INTERNAL_TITLE_MARKERS):
603
+ return ""
604
+ return text[:MAX_TITLE_CHARS]
605
+
606
+
607
+ def _timestamp_from_epoch(value: object) -> datetime | None:
608
+ if not isinstance(value, int):
609
+ return None
610
+ seconds = value / 1000 if abs(value) >= 1_577_836_800_000 else value
611
+ try:
612
+ return datetime.fromtimestamp(seconds, tz=UTC)
613
+ except (OverflowError, OSError, ValueError):
614
+ return None
615
+
616
+
617
+ def _mtime_ns(path: Path) -> int:
618
+ try:
619
+ return path.stat().st_mtime_ns
620
+ except OSError:
621
+ return 0
622
+
623
+
624
+ def _fingerprint(native_id: str, rollout_path: Path) -> str:
625
+ stat = rollout_path.stat()
626
+ value = "\0".join(
627
+ (native_id, str(rollout_path), str(stat.st_size), str(stat.st_mtime_ns))
628
+ )
629
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()