agent-decoder 0.3.2__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 (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
@@ -0,0 +1,81 @@
1
+ """Claude Code provider adapter.
2
+
3
+ Wraps the existing ``scanner.scan_paths`` and ``parser.parse_session``
4
+ implementations behind the ``ProviderAdapter`` seam. No scanning or parsing
5
+ logic lives here — this is pure delegation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator
11
+ from pathlib import Path
12
+
13
+ from ..models import AppConfig, Message, Project, Session
14
+ from .base import ProviderAdapter, ProviderCaps
15
+
16
+
17
+ class ClaudeAdapter(ProviderAdapter):
18
+ """Adapter for Claude Code's on-disk JSONL session format.
19
+
20
+ Session layout::
21
+
22
+ <root>/projects/<mangled-dir>/<uuid>.jsonl
23
+
24
+ All four interactive operations (send / resume / fork / edit) are
25
+ supported because Claude Code exposes a full PTY interface.
26
+ """
27
+
28
+ name = "claude"
29
+
30
+ # -- Capability declaration -----------------------------------------------
31
+
32
+ @property
33
+ def capabilities(self) -> ProviderCaps:
34
+ return ProviderCaps(
35
+ can_send=True, can_resume=True, can_fork=True, can_edit=True
36
+ )
37
+
38
+ # -- Config-aware root resolution -----------------------------------------
39
+
40
+ def configured_roots(self, config: AppConfig) -> list[Path]:
41
+ """Return the expanded root directories configured for Claude Code.
42
+
43
+ Mirrors ``server._all_scan_roots()`` — reads ``config.get_all_scan_paths()``
44
+ and expands ``~`` / env vars.
45
+ """
46
+ return [Path(p).expanduser() for p in config.get_all_scan_paths()]
47
+
48
+ # -- Discovery (async generator) ------------------------------------------
49
+
50
+ async def discover(self, roots: list[Path]) -> AsyncIterator[tuple[Project, Path]]:
51
+ """Yield ``(Project, session_file_path)`` by delegating to ``scan_paths``."""
52
+ from ..scanner import scan_paths
53
+
54
+ async for item in scan_paths(roots):
55
+ yield item
56
+
57
+ # -- Parsing (synchronous) ------------------------------------------------
58
+
59
+ def parse(self, path: Path) -> tuple[Session, list[Message]]:
60
+ """Parse *path* by delegating to ``parse_session``.
61
+
62
+ Sets ``session.provider = "claude"`` explicitly even though
63
+ ``Session`` already defaults to ``"claude"``, so the field is
64
+ always visibly owned by this adapter.
65
+ """
66
+ from ..parser import parse_session
67
+
68
+ session, messages = parse_session(path)
69
+ session.provider = "claude"
70
+ return session, messages
71
+
72
+ # -- Path ownership -------------------------------------------------------
73
+
74
+ def owns_path(self, path: Path) -> bool:
75
+ """Return True for ``.jsonl`` files nested under a ``projects/`` directory.
76
+
77
+ Claude sessions live at ``<root>/projects/<mangled-dir>/<uuid>.jsonl``.
78
+ Codex sessions live under ``.codex/sessions/…`` (no ``projects/`` dir),
79
+ so these two sets are disjoint.
80
+ """
81
+ return path.suffix == ".jsonl" and "projects" in path.parts
@@ -0,0 +1,441 @@
1
+ """OpenAI Codex CLI provider adapter (read-only, Phase 2).
2
+
3
+ Decodes Codex rollout JSONL files (rollout-*.jsonl) into clau-decode's
4
+ normalized domain model. Interactive operations (send / resume / fork /
5
+ edit) are disabled in this phase; Phase 4 will flip can_send / can_resume.
6
+
7
+ Rollout file structure (one JSON object per line, each with
8
+ ``{"timestamp", "type", "payload"}``):
9
+
10
+ session_meta — session id, cwd, git branch, originator
11
+ turn_context — model name (first one wins)
12
+ response_item — the canonical message content (type in {message,
13
+ reasoning, function_call, function_call_output})
14
+ event_msg — UI telemetry; only token_count + task_complete are
15
+ ingested; the rest are ignored
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ from collections.abc import AsyncIterator
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from ..models import (
28
+ AppConfig,
29
+ Message,
30
+ Project,
31
+ Session,
32
+ TextBlock,
33
+ ThinkingBlock,
34
+ TokenUsage,
35
+ ToolResultBlock,
36
+ ToolUseBlock,
37
+ )
38
+ from ..parser import _infer_title_from_messages
39
+ from .base import ProviderAdapter, ProviderCaps
40
+
41
+ # Placeholder text emitted for encrypted reasoning blocks.
42
+ # The real encrypted_content is NEVER read or stored.
43
+ _REASONING_PLACEHOLDER = "🔒 Reasoning (encrypted)"
44
+
45
+ # Codex injects environment/state context as ``user``-role messages whose text
46
+ # is an XML wrapper (no human prose) — the actual user prompt arrives in a
47
+ # later user message. We flag these as ``is_meta`` so they're excluded from
48
+ # title inference and the user-message count, mirroring how the Claude parser
49
+ # marks injected context. Detection: the message text begins with one of these
50
+ # wrapper tags. ``<turn_aborted>`` is Codex's interruption note (also meta).
51
+ _CODEX_META_WRAPPERS = (
52
+ "<environment_context",
53
+ "<workspace_roots",
54
+ "<permission_profile",
55
+ "<user_instructions",
56
+ "<turn_aborted",
57
+ )
58
+
59
+
60
+ def _is_injected_context(blocks: list[TextBlock]) -> bool:
61
+ """True if a user message is Codex-injected context rather than a prompt."""
62
+ text = "".join(b.text for b in blocks).lstrip()
63
+ return text.startswith(_CODEX_META_WRAPPERS)
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Project helper
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _codex_project(cwd: str | None, data_source: str) -> Project:
72
+ """Build a Project from a Codex session's working directory.
73
+
74
+ Args:
75
+ cwd: The ``cwd`` field from ``session_meta.payload``, or
76
+ ``None`` if the field was absent.
77
+ data_source: The configured root path this session was found under.
78
+
79
+ Returns:
80
+ A Project with a stable 16-hex-char id and human-readable display_name.
81
+ """
82
+ if cwd:
83
+ project_id = hashlib.sha256(
84
+ f"{cwd}\0{data_source}\0codex".encode()
85
+ ).hexdigest()[:16]
86
+ parts = [p for p in cwd.split("/") if p]
87
+ if len(parts) >= 2:
88
+ display_name = "/".join(parts[-2:])
89
+ elif parts:
90
+ display_name = parts[0]
91
+ else:
92
+ display_name = cwd
93
+ resolved: str | None = cwd if Path(cwd).exists() else None
94
+ return Project(
95
+ id=project_id,
96
+ display_name=display_name,
97
+ raw_path=cwd,
98
+ resolved_path=resolved,
99
+ data_source=data_source,
100
+ )
101
+ else:
102
+ sentinel = "codex/(no project)"
103
+ project_id = hashlib.sha256(
104
+ f"{sentinel}\0{data_source}\0codex".encode()
105
+ ).hexdigest()[:16]
106
+ return Project(
107
+ id=project_id,
108
+ display_name=sentinel,
109
+ raw_path=sentinel,
110
+ resolved_path=None,
111
+ data_source=data_source,
112
+ )
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Timestamp helper
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def _parse_ts(ts: str) -> datetime:
121
+ """Parse an ISO-8601 timestamp string, handling trailing ``Z``."""
122
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Adapter
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ class CodexAdapter(ProviderAdapter):
131
+ """Adapter for OpenAI Codex CLI rollout JSONL files (read-only).
132
+
133
+ Session layout::
134
+
135
+ <root>/**/rollout-<id>.jsonl
136
+
137
+ All four interactive operations are disabled (read-only Phase 2).
138
+ """
139
+
140
+ name = "codex"
141
+
142
+ # -- Capability declaration -----------------------------------------------
143
+
144
+ @property
145
+ def capabilities(self) -> ProviderCaps:
146
+ # Phase 4e: Codex is drivable (send + resume) via the tmux ProviderDriver.
147
+ # These are STATIC caps; the server reconciles them with runtime
148
+ # availability (``_effective_caps``) so a box without tmux/codex still
149
+ # degrades to read-only. fork/edit stay False — no flow exists yet and
150
+ # the Claude-JSONL editor would corrupt a Codex rollout.
151
+ return ProviderCaps(
152
+ can_send=True, can_resume=True, can_fork=False, can_edit=False
153
+ )
154
+
155
+ # -- Config-aware root resolution -----------------------------------------
156
+
157
+ def configured_roots(self, config: AppConfig) -> list[Path]:
158
+ """Return the expanded root directories configured for Codex sessions."""
159
+ return [Path(p).expanduser() for p in config.codex_data_paths]
160
+
161
+ # -- Path ownership -------------------------------------------------------
162
+
163
+ def owns_path(self, path: Path) -> bool:
164
+ """Return True for ``rollout-*.jsonl`` files."""
165
+ return path.suffix == ".jsonl" and path.name.startswith("rollout-")
166
+
167
+ # -- Discovery (async generator) ------------------------------------------
168
+
169
+ async def discover(self, roots: list[Path]) -> AsyncIterator[tuple[Project, Path]]:
170
+ """Yield ``(Project, session_file_path)`` pairs found under *roots*.
171
+
172
+ Reads only the first non-empty line of each file to extract the cwd;
173
+ no full parse is performed during discovery.
174
+ """
175
+ for root in roots:
176
+ if not root.exists():
177
+ continue
178
+ for path in sorted(root.rglob("rollout-*.jsonl")):
179
+ cwd: str | None = None
180
+ try:
181
+ with path.open(encoding="utf-8") as fh:
182
+ for raw in fh:
183
+ raw = raw.strip()
184
+ if not raw:
185
+ continue
186
+ record: dict[str, Any] = json.loads(raw)
187
+ if record.get("type") == "session_meta":
188
+ cwd = record.get("payload", {}).get("cwd")
189
+ break # only the first non-empty line
190
+ except (OSError, json.JSONDecodeError):
191
+ pass
192
+ project = _codex_project(cwd, str(root))
193
+ yield project, path
194
+
195
+ # -- Parsing (synchronous) ------------------------------------------------
196
+
197
+ def parse(self, path: Path) -> tuple[Session, list[Message]]:
198
+ """Parse a Codex rollout JSONL file into ``(Session, list[Message])``.
199
+
200
+ Builds messages from ``response_item`` records only. ``event_msg``
201
+ records are used for token usage (``token_count``) and fallback title
202
+ (``task_complete``). Reasoning blocks carry a placeholder instead of
203
+ the encrypted content.
204
+
205
+ Linear ``id``/``parent_id`` chains are synthesised so that
206
+ ``build_message_tree`` yields a single flat chronological thread.
207
+ """
208
+ session_id: str | None = None
209
+ cwd: str | None = None
210
+ git_branch: str | None = None
211
+ session_started_at: datetime | None = None
212
+ model: str | None = None
213
+ last_usage: TokenUsage | None = None
214
+ fallback_title: str | None = None
215
+
216
+ # Pending assistant turn state
217
+ _turn_blocks: list[Any] = []
218
+ _turn_ts: datetime | None = None
219
+ _turn_has_reasoning: bool = False
220
+
221
+ messages: list[Message] = []
222
+
223
+ def flush() -> None:
224
+ """Finalise the open assistant turn into one Message."""
225
+ nonlocal _turn_blocks, _turn_ts, _turn_has_reasoning
226
+ if not _turn_blocks:
227
+ return
228
+ msg = Message(
229
+ id="__placeholder__", # replaced after all messages collected
230
+ session_id=session_id or "",
231
+ role="assistant",
232
+ content_blocks=list(_turn_blocks),
233
+ timestamp=_turn_ts,
234
+ model=model,
235
+ usage=last_usage,
236
+ provider="codex",
237
+ )
238
+ messages.append(msg)
239
+ _turn_blocks = []
240
+ _turn_ts = None
241
+ _turn_has_reasoning = False
242
+
243
+ def open_turn(ts: datetime) -> None:
244
+ """Start an assistant turn if none is open."""
245
+ nonlocal _turn_ts
246
+ if _turn_ts is None:
247
+ _turn_ts = ts
248
+
249
+ with path.open(encoding="utf-8") as fh:
250
+ for raw_line in fh:
251
+ raw_line = raw_line.strip()
252
+ if not raw_line:
253
+ continue
254
+ try:
255
+ record: dict[str, Any] = json.loads(raw_line)
256
+ except json.JSONDecodeError:
257
+ continue
258
+
259
+ rec_type = record.get("type")
260
+ payload: dict[str, Any] = record.get("payload", {}) or {}
261
+ ts_str: str = record.get("timestamp", "")
262
+ ts: datetime | None = None
263
+ if ts_str:
264
+ try:
265
+ ts = _parse_ts(ts_str)
266
+ except ValueError:
267
+ pass
268
+
269
+ # ── session_meta ───────────────────────────────────────────
270
+ if rec_type == "session_meta":
271
+ session_id = payload.get("id")
272
+ cwd = payload.get("cwd")
273
+ git_branch = (payload.get("git") or {}).get("branch")
274
+ meta_ts_str = payload.get("timestamp", "")
275
+ if meta_ts_str:
276
+ try:
277
+ session_started_at = _parse_ts(meta_ts_str)
278
+ except ValueError:
279
+ pass
280
+
281
+ # ── turn_context ───────────────────────────────────────────
282
+ elif rec_type == "turn_context":
283
+ if model is None:
284
+ model = payload.get("model")
285
+
286
+ # ── response_item ──────────────────────────────────────────
287
+ elif rec_type == "response_item":
288
+ item_type = payload.get("type")
289
+
290
+ if item_type == "message":
291
+ role = payload.get("role", "")
292
+ content = payload.get("content") or []
293
+
294
+ if role == "user":
295
+ flush()
296
+ blocks = [
297
+ TextBlock(text=c["text"])
298
+ for c in content
299
+ if c.get("type") == "input_text"
300
+ ]
301
+ msg = Message(
302
+ id="__placeholder__",
303
+ session_id=session_id or "",
304
+ role="user",
305
+ is_meta=_is_injected_context(blocks),
306
+ content_blocks=blocks,
307
+ timestamp=ts,
308
+ provider="codex",
309
+ )
310
+ messages.append(msg)
311
+
312
+ elif role == "developer":
313
+ flush()
314
+ blocks = [
315
+ TextBlock(text=c["text"])
316
+ for c in content
317
+ if c.get("type") == "input_text"
318
+ ]
319
+ msg = Message(
320
+ id="__placeholder__",
321
+ session_id=session_id or "",
322
+ role="user",
323
+ is_meta=True,
324
+ content_blocks=blocks,
325
+ timestamp=ts,
326
+ provider="codex",
327
+ )
328
+ messages.append(msg)
329
+
330
+ elif role == "assistant":
331
+ if ts is not None:
332
+ open_turn(ts)
333
+ for c in content:
334
+ if c.get("type") == "output_text":
335
+ _turn_blocks.append(TextBlock(text=c["text"]))
336
+
337
+ elif item_type == "reasoning":
338
+ if ts is not None:
339
+ open_turn(ts)
340
+ if not _turn_has_reasoning:
341
+ _turn_blocks.append(
342
+ ThinkingBlock(thinking=_REASONING_PLACEHOLDER)
343
+ )
344
+ _turn_has_reasoning = True
345
+
346
+ elif item_type == "function_call":
347
+ if ts is not None:
348
+ open_turn(ts)
349
+ call_id: str = payload.get("call_id", "")
350
+ name: str = payload.get("name", "")
351
+ arguments: str = payload.get("arguments", "")
352
+ try:
353
+ parsed_args = json.loads(arguments)
354
+ if not isinstance(parsed_args, dict):
355
+ raise ValueError("not a dict")
356
+ except (json.JSONDecodeError, ValueError):
357
+ parsed_args = {"_raw": arguments}
358
+ _turn_blocks.append(
359
+ ToolUseBlock(id=call_id, name=name, input=parsed_args)
360
+ )
361
+
362
+ elif item_type == "function_call_output":
363
+ if ts is not None:
364
+ open_turn(ts)
365
+ call_id = payload.get("call_id", "")
366
+ output: str = payload.get("output", "")
367
+ _turn_blocks.append(
368
+ ToolResultBlock(tool_use_id=call_id, content=output)
369
+ )
370
+
371
+ # ── event_msg ──────────────────────────────────────────────
372
+ elif rec_type == "event_msg":
373
+ ev_type = payload.get("type")
374
+
375
+ if ev_type == "token_count":
376
+ info = payload.get("info")
377
+ if isinstance(info, dict):
378
+ ltu = info.get("last_token_usage")
379
+ if isinstance(ltu, dict):
380
+ last_usage = TokenUsage(
381
+ input_tokens=ltu.get("input_tokens", 0),
382
+ cache_read_input_tokens=ltu.get(
383
+ "cached_input_tokens", 0
384
+ ),
385
+ output_tokens=ltu.get("output_tokens", 0),
386
+ cache_creation_input_tokens=0,
387
+ )
388
+
389
+ elif ev_type == "task_complete":
390
+ msg_text = payload.get("last_agent_message")
391
+ if msg_text:
392
+ fallback_title = msg_text
393
+
394
+ # Finalise any open assistant turn
395
+ flush()
396
+
397
+ # Assign linear ids and parent_id chain
398
+ if not session_id:
399
+ session_id = path.stem # last resort: use filename stem
400
+
401
+ for i, msg in enumerate(messages):
402
+ msg.id = f"{session_id}-{i:04d}"
403
+ msg.session_id = session_id
404
+ msg.parent_id = messages[i - 1].id if i > 0 else None
405
+
406
+ # Build session timestamps (min/max over message timestamps)
407
+ all_ts = [m.timestamp for m in messages if m.timestamp is not None]
408
+ if session_started_at is not None:
409
+ all_ts.append(session_started_at)
410
+
411
+ started_at: datetime | None = session_started_at
412
+ updated_at: datetime | None = None
413
+ if all_ts:
414
+ started_at = min(all_ts)
415
+ updated_at = max(all_ts)
416
+
417
+ # Build project for project_id (best-effort; server stamps authoritative id)
418
+ project = _codex_project(cwd, "")
419
+
420
+ session = Session(
421
+ id=session_id,
422
+ project_id=project.id,
423
+ file_path=str(path),
424
+ cwd=cwd,
425
+ git_branch=git_branch,
426
+ model=model,
427
+ started_at=started_at,
428
+ updated_at=updated_at,
429
+ provider="codex",
430
+ message_count=len(messages),
431
+ user_message_count=sum(
432
+ 1 for m in messages if m.role == "user" and not m.is_meta
433
+ ),
434
+ )
435
+
436
+ # Title: infer from first user message, fallback to task_complete, then hardcoded
437
+ session.title = (
438
+ _infer_title_from_messages(messages) or fallback_title or "Codex session"
439
+ )
440
+
441
+ return session, messages
@@ -0,0 +1,89 @@
1
+ """Provider registry — runtime catalogue of registered ``ProviderAdapter`` instances.
2
+
3
+ Registration is explicit (call ``register(adapter)`` at import time or in an
4
+ app init hook). The registry is a plain module-level ordered dict so there
5
+ are no hidden singletons and tests can ``clear()`` between cases.
6
+
7
+ Typical usage::
8
+
9
+ from clau_decode.providers import registry
10
+ from clau_decode.providers.claude import ClaudeAdapter
11
+
12
+ registry.register(ClaudeAdapter())
13
+
14
+ # Dispatch by name:
15
+ adapter = registry.get("claude")
16
+
17
+ # Dispatch by file path (first adapter whose owns_path returns True):
18
+ adapter = registry.adapter_for_path(Path("/home/user/.claude/projects/…/sess.jsonl"))
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+
25
+ from .base import ProviderAdapter
26
+
27
+ # Ordered dict keyed by adapter.name. Insertion order is preserved (Python
28
+ # 3.7+) and exposed via all_adapters().
29
+ _registry: dict[str, ProviderAdapter] = {}
30
+
31
+
32
+ def register(adapter: ProviderAdapter) -> None:
33
+ """Register *adapter* under ``adapter.name``, replacing any prior entry.
34
+
35
+ Args:
36
+ adapter: A concrete ``ProviderAdapter`` instance.
37
+ """
38
+ _registry[adapter.name] = adapter
39
+
40
+
41
+ def get(name: str) -> ProviderAdapter:
42
+ """Return the adapter registered under *name*.
43
+
44
+ Args:
45
+ name: The short provider identifier (e.g. ``"claude"``).
46
+
47
+ Raises:
48
+ KeyError: If no adapter with that name has been registered.
49
+ """
50
+ if name not in _registry:
51
+ registered = list(_registry.keys())
52
+ raise KeyError(
53
+ f"No provider adapter registered for {name!r}. "
54
+ f"Registered providers: {registered}"
55
+ )
56
+ return _registry[name]
57
+
58
+
59
+ def all_adapters() -> list[ProviderAdapter]:
60
+ """Return all registered adapters in insertion order."""
61
+ return list(_registry.values())
62
+
63
+
64
+ def adapter_for_path(path: Path) -> ProviderAdapter | None:
65
+ """Return the first adapter that claims ownership of *path*, or ``None``.
66
+
67
+ Iterates in insertion order so registration order determines priority when
68
+ multiple adapters could theoretically match the same path.
69
+
70
+ Args:
71
+ path: Absolute path to a candidate session file.
72
+
73
+ Returns:
74
+ The first ``ProviderAdapter`` whose ``owns_path(path)`` returns True,
75
+ or ``None`` if no registered adapter claims it.
76
+ """
77
+ for adapter in _registry.values():
78
+ if adapter.owns_path(path):
79
+ return adapter
80
+ return None
81
+
82
+
83
+ def clear() -> None:
84
+ """Remove all registered adapters.
85
+
86
+ Intended for use in test teardown so registry state does not leak between
87
+ test cases.
88
+ """
89
+ _registry.clear()
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from typing import Any
5
+
6
+
7
+ def decode_terminal_input(data: str) -> bytes:
8
+ return data.encode("utf-8", errors="surrogatepass")
9
+
10
+
11
+ def encode_pty_output_chunk(session_id: str, chunk: bytes) -> dict[str, Any]:
12
+ return {
13
+ "type": "pty_output_chunk",
14
+ "session_id": session_id,
15
+ "data_b64": base64.b64encode(chunk).decode("ascii"),
16
+ }
17
+
18
+
19
+ def encode_pty_snapshot(
20
+ *,
21
+ session_id: str,
22
+ ring: bytes,
23
+ ring_complete: bool,
24
+ rows: int,
25
+ cols: int,
26
+ alive: bool,
27
+ native_state: str,
28
+ decoded_input_safe: bool,
29
+ ) -> dict[str, Any]:
30
+ return {
31
+ "session_id": session_id,
32
+ "ring_b64": base64.b64encode(ring).decode("ascii"),
33
+ "ring_complete": ring_complete,
34
+ "rows": rows,
35
+ "cols": cols,
36
+ "alive": alive,
37
+ "native_state": native_state,
38
+ "decoded_input_safe": decoded_input_safe,
39
+ }