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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- 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()
|