byte-bot 0.1.0__tar.gz

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 (38) hide show
  1. byte_bot-0.1.0/.gitignore +9 -0
  2. byte_bot-0.1.0/LICENSE +21 -0
  3. byte_bot-0.1.0/PKG-INFO +116 -0
  4. byte_bot-0.1.0/README.md +101 -0
  5. byte_bot-0.1.0/pyproject.toml +40 -0
  6. byte_bot-0.1.0/src/byte_bot/__init__.py +3 -0
  7. byte_bot-0.1.0/src/byte_bot/__main__.py +3 -0
  8. byte_bot-0.1.0/src/byte_bot/adapters/__init__.py +16 -0
  9. byte_bot-0.1.0/src/byte_bot/adapters/codex_exec_jsonl.py +325 -0
  10. byte_bot-0.1.0/src/byte_bot/adapters/legacy.py +506 -0
  11. byte_bot-0.1.0/src/byte_bot/adapters/opencode_http.py +968 -0
  12. byte_bot-0.1.0/src/byte_bot/app.py +1965 -0
  13. byte_bot-0.1.0/src/byte_bot/cli.py +2518 -0
  14. byte_bot-0.1.0/src/byte_bot/config.py +1097 -0
  15. byte_bot-0.1.0/src/byte_bot/demo.py +457 -0
  16. byte_bot-0.1.0/src/byte_bot/disclosure.py +212 -0
  17. byte_bot-0.1.0/src/byte_bot/discovery.py +303 -0
  18. byte_bot-0.1.0/src/byte_bot/doctor.py +623 -0
  19. byte_bot-0.1.0/src/byte_bot/http_deadline.py +198 -0
  20. byte_bot-0.1.0/src/byte_bot/journal_bridge.py +1382 -0
  21. byte_bot-0.1.0/src/byte_bot/migration.py +420 -0
  22. byte_bot-0.1.0/src/byte_bot/mobile.py +479 -0
  23. byte_bot-0.1.0/src/byte_bot/model.py +1248 -0
  24. byte_bot-0.1.0/src/byte_bot/notifications.py +1130 -0
  25. byte_bot-0.1.0/src/byte_bot/onboarding.py +282 -0
  26. byte_bot-0.1.0/src/byte_bot/platform_migration.py +228 -0
  27. byte_bot-0.1.0/src/byte_bot/presentation.py +495 -0
  28. byte_bot-0.1.0/src/byte_bot/projects.py +934 -0
  29. byte_bot-0.1.0/src/byte_bot/repository.py +151 -0
  30. byte_bot-0.1.0/src/byte_bot/service_manager.py +240 -0
  31. byte_bot-0.1.0/src/byte_bot/state.py +901 -0
  32. byte_bot-0.1.0/src/byte_bot/storage.py +443 -0
  33. byte_bot-0.1.0/src/byte_bot/structured_source.py +613 -0
  34. byte_bot-0.1.0/src/byte_bot/systemd_unit.py +227 -0
  35. byte_bot-0.1.0/src/byte_bot/telegram.py +634 -0
  36. byte_bot-0.1.0/src/byte_bot/telegram_setup.py +228 -0
  37. byte_bot-0.1.0/src/byte_bot/tui.py +738 -0
  38. byte_bot-0.1.0/src/byte_bot/updater.py +226 -0
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .coverage
7
+ dist/
8
+ build/
9
+ *.egg-info/
byte_bot-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MD Raqibul Islam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: byte-bot
3
+ Version: 0.1.0
4
+ Summary: Local dashboard for monitoring coding agents with optional Telegram status
5
+ Project-URL: Homepage, https://github.com/erajtob/byte-bot
6
+ Project-URL: Source, https://github.com/erajtob/byte-bot
7
+ Project-URL: Issues, https://github.com/erajtob/byte-bot/issues
8
+ Project-URL: Security, https://github.com/erajtob/byte-bot/security
9
+ Author-email: MD Raqibul Islam <hello@eraj.dev>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: textual<9,>=8.2.8
14
+ Description-Content-Type: text/markdown
15
+
16
+ <p align="center">
17
+ <img src="docs/assets/byte-wordmark.svg" alt="byte-bot wordmark" width="760">
18
+ </p>
19
+
20
+ # byte-bot
21
+
22
+ **See what your coding agents are doing — and know when they need you.**
23
+
24
+ Byte is a small, local dashboard for coding agents. It watches supported agents
25
+ without controlling them, gives you one terminal view, and can optionally send the same
26
+ status to a private Telegram chat.
27
+
28
+ <p align="center">
29
+ <img src="docs/assets/dashboard-demo.svg" alt="Synthetic byte-bot dashboard activity demo" width="1000">
30
+ </p>
31
+
32
+ *The dashboard above uses synthetic demo data.*
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ curl -fsSL https://raw.githubusercontent.com/erajtob/byte-bot/main/install.sh | sh
38
+ ```
39
+
40
+ ## Run
41
+
42
+ ```bash
43
+ byte-bot
44
+ ```
45
+
46
+ On first launch, choose the demo or follow the guided setup for your machine.
47
+
48
+ Already installed?
49
+
50
+ ```bash
51
+ byte-bot update
52
+ ```
53
+
54
+ **Beta status:** Linux-first. macOS has a foreground path but is not Beta-verified yet;
55
+ Windows is not Beta-supported yet.
56
+
57
+ ## What you can see
58
+
59
+ - active supported agent sessions, grouped by project when Byte can prove the workspace;
60
+ - a short current-activity line such as reading files, editing, running a command, or thinking;
61
+ - model and usage information when the source provides it;
62
+ - permission or input blockers that need you;
63
+ - whether status is fresh or stale, and when a source could not be read.
64
+
65
+ Byte does not store or display private chain-of-thought, prompt bodies, command output,
66
+ or raw tool arguments as status.
67
+
68
+ ## Telegram (optional)
69
+
70
+ <p align="center">
71
+ <img src="docs/assets/telegram-preview.svg" alt="Synthetic byte-bot Telegram preview" width="760">
72
+ </p>
73
+
74
+ Pair Byte with one private Telegram chat to check status away from the terminal and receive
75
+ quiet notifications when an agent needs attention.
76
+
77
+ Telegram does **not** become a shell. It cannot change configured commands, start agents,
78
+ control Docker or sudo, browse the filesystem, or turn chat text into command input.
79
+
80
+ ## Supported today
81
+
82
+ | Source | What Byte currently supports |
83
+ | --- | --- |
84
+ | OpenCode | Active sessions, projects, model/usage, current activity, and permission/input waits |
85
+ | Codex CLI 0.155.1 | Passive observation of an existing `exec --json` capture |
86
+ | Structured project sources | Status and events provided by a project adapter |
87
+ | Local status commands | Exact read-only commands you configure, with timeouts |
88
+
89
+ OpenCode is not pinned to one release number. Byte checks the small set of data it
90
+ relies on; if that data becomes incompatible, Byte marks the source degraded instead
91
+ of guessing.
92
+
93
+ ## Read-only by design
94
+
95
+ byte-bot v0.1 only observes:
96
+
97
+ - Telegram cannot run arbitrary commands or control your agents;
98
+ - Byte only runs the exact local status commands you configure, with timeouts;
99
+ - there is no hosted Byte account; optional Telegram uses outbound connections only.
100
+
101
+ For the technical details, read [Security](SECURITY.md),
102
+ [How it works](docs/how-it-works.md), and [Compatibility](docs/compatibility.md).
103
+
104
+ ## More
105
+
106
+ - [Install, update, and remove](docs/install.md)
107
+ - [Setup and configuration](docs/install.md#first-launch)
108
+ - [Support and troubleshooting](docs/support.md)
109
+ - [How it works](docs/how-it-works.md)
110
+ - [Contributing](CONTRIBUTING.md)
111
+
112
+ byte-bot stays project-agnostic and does not require any particular workload.
113
+
114
+ ## License
115
+
116
+ [MIT](LICENSE).
@@ -0,0 +1,101 @@
1
+ <p align="center">
2
+ <img src="docs/assets/byte-wordmark.svg" alt="byte-bot wordmark" width="760">
3
+ </p>
4
+
5
+ # byte-bot
6
+
7
+ **See what your coding agents are doing — and know when they need you.**
8
+
9
+ Byte is a small, local dashboard for coding agents. It watches supported agents
10
+ without controlling them, gives you one terminal view, and can optionally send the same
11
+ status to a private Telegram chat.
12
+
13
+ <p align="center">
14
+ <img src="docs/assets/dashboard-demo.svg" alt="Synthetic byte-bot dashboard activity demo" width="1000">
15
+ </p>
16
+
17
+ *The dashboard above uses synthetic demo data.*
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ curl -fsSL https://raw.githubusercontent.com/erajtob/byte-bot/main/install.sh | sh
23
+ ```
24
+
25
+ ## Run
26
+
27
+ ```bash
28
+ byte-bot
29
+ ```
30
+
31
+ On first launch, choose the demo or follow the guided setup for your machine.
32
+
33
+ Already installed?
34
+
35
+ ```bash
36
+ byte-bot update
37
+ ```
38
+
39
+ **Beta status:** Linux-first. macOS has a foreground path but is not Beta-verified yet;
40
+ Windows is not Beta-supported yet.
41
+
42
+ ## What you can see
43
+
44
+ - active supported agent sessions, grouped by project when Byte can prove the workspace;
45
+ - a short current-activity line such as reading files, editing, running a command, or thinking;
46
+ - model and usage information when the source provides it;
47
+ - permission or input blockers that need you;
48
+ - whether status is fresh or stale, and when a source could not be read.
49
+
50
+ Byte does not store or display private chain-of-thought, prompt bodies, command output,
51
+ or raw tool arguments as status.
52
+
53
+ ## Telegram (optional)
54
+
55
+ <p align="center">
56
+ <img src="docs/assets/telegram-preview.svg" alt="Synthetic byte-bot Telegram preview" width="760">
57
+ </p>
58
+
59
+ Pair Byte with one private Telegram chat to check status away from the terminal and receive
60
+ quiet notifications when an agent needs attention.
61
+
62
+ Telegram does **not** become a shell. It cannot change configured commands, start agents,
63
+ control Docker or sudo, browse the filesystem, or turn chat text into command input.
64
+
65
+ ## Supported today
66
+
67
+ | Source | What Byte currently supports |
68
+ | --- | --- |
69
+ | OpenCode | Active sessions, projects, model/usage, current activity, and permission/input waits |
70
+ | Codex CLI 0.155.1 | Passive observation of an existing `exec --json` capture |
71
+ | Structured project sources | Status and events provided by a project adapter |
72
+ | Local status commands | Exact read-only commands you configure, with timeouts |
73
+
74
+ OpenCode is not pinned to one release number. Byte checks the small set of data it
75
+ relies on; if that data becomes incompatible, Byte marks the source degraded instead
76
+ of guessing.
77
+
78
+ ## Read-only by design
79
+
80
+ byte-bot v0.1 only observes:
81
+
82
+ - Telegram cannot run arbitrary commands or control your agents;
83
+ - Byte only runs the exact local status commands you configure, with timeouts;
84
+ - there is no hosted Byte account; optional Telegram uses outbound connections only.
85
+
86
+ For the technical details, read [Security](SECURITY.md),
87
+ [How it works](docs/how-it-works.md), and [Compatibility](docs/compatibility.md).
88
+
89
+ ## More
90
+
91
+ - [Install, update, and remove](docs/install.md)
92
+ - [Setup and configuration](docs/install.md#first-launch)
93
+ - [Support and troubleshooting](docs/support.md)
94
+ - [How it works](docs/how-it-works.md)
95
+ - [Contributing](CONTRIBUTING.md)
96
+
97
+ byte-bot stays project-agnostic and does not require any particular workload.
98
+
99
+ ## License
100
+
101
+ [MIT](LICENSE).
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "byte-bot"
7
+ version = "0.1.0"
8
+ description = "Local dashboard for monitoring coding agents with optional Telegram status"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "MD Raqibul Islam", email = "hello@eraj.dev"}]
13
+ dependencies = [
14
+ "textual>=8.2.8,<9",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/erajtob/byte-bot"
19
+ Source = "https://github.com/erajtob/byte-bot"
20
+ Issues = "https://github.com/erajtob/byte-bot/issues"
21
+ Security = "https://github.com/erajtob/byte-bot/security"
22
+
23
+ [project.scripts]
24
+ byte-bot = "byte_bot.cli:main"
25
+
26
+ [dependency-groups]
27
+ dev = ["ruff>=0.13,<1"]
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/byte_bot"]
31
+
32
+ [tool.hatch.build.targets.sdist]
33
+ include = ["/src/byte_bot", "/README.md", "/LICENSE", "/pyproject.toml"]
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+ target-version = "py312"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,3 @@
1
+ """byte-bot."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from byte_bot.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ """Static reviewed adapter-name registry.
2
+
3
+ Importing the package must not eagerly import adapter implementations: config and
4
+ legacy adapters depend on each other at module-load time. Runtime code imports the
5
+ specific reviewed implementation it needs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ ADAPTER_NAMES: tuple[str, ...] = (
11
+ "codex-exec-jsonl",
12
+ "legacy-command",
13
+ "opencode-http",
14
+ )
15
+
16
+ __all__ = ["ADAPTER_NAMES"]
@@ -0,0 +1,325 @@
1
+ """Version-pinned passive Codex exec JSONL observation.
2
+
3
+ The adapter reads one explicitly configured existing capture. It never invokes
4
+ Codex, resumes a thread, attaches to a session, or retains transcript/tool bodies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import json
11
+ import os
12
+ import stat as stat_module
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from byte_bot.model import (
19
+ CollectionError,
20
+ Evidence,
21
+ Lifecycle,
22
+ Session,
23
+ SourceHealth,
24
+ SourceStatus,
25
+ )
26
+
27
+ ADAPTER_NAME = "codex-exec-jsonl"
28
+ SUPPORTED_VERSION = "0.155.1"
29
+ FIELD_VERSION = "codex-0.155.1-exec-jsonl"
30
+ CAPABILITIES = ("sessions", "source-health")
31
+ MAX_CAPTURE_BYTES = 64 * 1024
32
+ MAX_RECORDS = 32
33
+ MAX_THREAD_ID_LEN = 128
34
+
35
+ _IGNORED_ITEM_TYPES = frozenset({"item.started", "item.updated", "item.completed"})
36
+ _ALLOWED_TYPES = frozenset(
37
+ {
38
+ "thread.started",
39
+ "turn.started",
40
+ "turn.completed",
41
+ "turn.failed",
42
+ "error",
43
+ *_IGNORED_ITEM_TYPES,
44
+ }
45
+ )
46
+
47
+
48
+ class _CodexFailure(Exception):
49
+ def __init__(self, code: str, message: str) -> None:
50
+ super().__init__(code)
51
+ self.code = code
52
+ self.message = message
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class CodexObservation:
57
+ sessions: tuple[Session, ...] = ()
58
+ errors: tuple[CollectionError, ...] = ()
59
+ health: SourceHealth | None = None
60
+ complete: bool | None = None
61
+ version: str | None = None
62
+
63
+
64
+ def _read_capture(path: Path) -> bytes:
65
+ """Read a bounded regular file without following a runtime symlink."""
66
+ path = Path(path)
67
+ # Reject FIFOs after opening without waiting for a writer, including
68
+ # regular-file replacement races between metadata checks and open().
69
+ flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
70
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
71
+ before: os.stat_result | None = None
72
+ if nofollow:
73
+ flags |= nofollow
74
+ else:
75
+ before = path.lstat()
76
+ if stat_module.S_ISLNK(before.st_mode):
77
+ raise _CodexFailure("unsafe_path", "Codex capture path is unsafe")
78
+
79
+ try:
80
+ fd = os.open(path, flags)
81
+ except OSError as exc:
82
+ raise _CodexFailure("capture_unavailable", "Codex capture is unavailable") from exc
83
+ try:
84
+ metadata = os.fstat(fd)
85
+ if not stat_module.S_ISREG(metadata.st_mode):
86
+ raise _CodexFailure("unsafe_path", "Codex capture path is unsafe")
87
+ if before is not None and (
88
+ before.st_dev != metadata.st_dev or before.st_ino != metadata.st_ino
89
+ ):
90
+ raise _CodexFailure("unsafe_path", "Codex capture changed while opening")
91
+ if metadata.st_size > MAX_CAPTURE_BYTES:
92
+ raise _CodexFailure(
93
+ "capture_too_large",
94
+ "Codex capture exceeds the validated byte limit",
95
+ )
96
+ data = os.read(fd, MAX_CAPTURE_BYTES + 1)
97
+ if len(data) > MAX_CAPTURE_BYTES:
98
+ raise _CodexFailure(
99
+ "capture_too_large",
100
+ "Codex capture exceeds the validated byte limit",
101
+ )
102
+ return data
103
+ finally:
104
+ with contextlib.suppress(OSError):
105
+ os.close(fd)
106
+
107
+
108
+ def _valid_thread_id(value: object) -> bool:
109
+ return (
110
+ isinstance(value, str)
111
+ and bool(value)
112
+ and len(value) <= MAX_THREAD_ID_LEN
113
+ and not any(ord(char) < 0x20 or 0x7F <= ord(char) <= 0x9F for char in value)
114
+ )
115
+
116
+
117
+ def _error(
118
+ source_id: str,
119
+ failure: _CodexFailure,
120
+ observed_at: datetime,
121
+ *,
122
+ version: str | None,
123
+ ) -> CodexObservation:
124
+ return CodexObservation(
125
+ errors=(
126
+ CollectionError(
127
+ code=failure.code,
128
+ message=failure.message,
129
+ source_id=source_id,
130
+ ),
131
+ ),
132
+ health=SourceHealth(
133
+ source_id=source_id,
134
+ status=SourceStatus.ERROR,
135
+ detail=failure.message,
136
+ observed_at=observed_at,
137
+ ),
138
+ version=version,
139
+ )
140
+
141
+
142
+ def _records(data: bytes) -> tuple[list[dict[str, Any]], bool]:
143
+ try:
144
+ text = data.decode("utf-8")
145
+ except UnicodeDecodeError:
146
+ raise _CodexFailure("malformed_capture", "Codex capture is not valid UTF-8") from None
147
+
148
+ partial = bool(data) and not data.endswith(b"\n")
149
+ lines = text.splitlines()
150
+ if partial and lines:
151
+ lines = lines[:-1]
152
+ lines = [line for line in lines if line.strip()]
153
+ if len(lines) > MAX_RECORDS:
154
+ raise _CodexFailure(
155
+ "too_many_records",
156
+ "Codex capture exceeds the validated record limit",
157
+ )
158
+
159
+ records: list[dict[str, Any]] = []
160
+ for line in lines:
161
+ try:
162
+ raw = json.loads(line)
163
+ except json.JSONDecodeError:
164
+ raise _CodexFailure(
165
+ "malformed_capture", "Codex capture contains invalid JSON"
166
+ ) from None
167
+ if not isinstance(raw, dict):
168
+ raise _CodexFailure("malformed_capture", "Codex capture record must be an object")
169
+ event_type = raw.get("type")
170
+ if not isinstance(event_type, str) or event_type not in _ALLOWED_TYPES:
171
+ raise _CodexFailure(
172
+ "unsupported_event",
173
+ "Codex capture contains an unsupported event type",
174
+ )
175
+ records.append(raw)
176
+ return records, partial
177
+
178
+
179
+ def observe_codex_capture(
180
+ *,
181
+ source_id: str,
182
+ path: Path,
183
+ source_version: str,
184
+ now: datetime | None = None,
185
+ ) -> CodexObservation:
186
+ """Map a validated Codex 0.155.1 exec capture to one typed session."""
187
+ observed_at = now or datetime.now(UTC)
188
+ version = source_version if isinstance(source_version, str) else None
189
+ try:
190
+ if source_version != SUPPORTED_VERSION:
191
+ raise _CodexFailure("unsupported_version", "Codex capture version is not supported")
192
+ records, partial = _records(_read_capture(path))
193
+ if not records:
194
+ raise _CodexFailure("empty_capture", "Codex capture contains no complete records")
195
+
196
+ thread_id: str | None = None
197
+ turn_started = False
198
+ lifecycle = Lifecycle.UNKNOWN
199
+ terminal = False
200
+
201
+ for raw in records:
202
+ event_type = raw["type"]
203
+ if event_type in _IGNORED_ITEM_TYPES:
204
+ continue
205
+
206
+ if event_type == "thread.started":
207
+ value = raw.get("thread_id")
208
+ if set(raw) != {"type", "thread_id"} or not _valid_thread_id(value):
209
+ raise _CodexFailure("malformed_thread", "Codex thread identity is invalid")
210
+ if thread_id is not None:
211
+ raise _CodexFailure(
212
+ "multiple_threads",
213
+ "Codex capture contains more than one thread",
214
+ )
215
+ thread_id = value
216
+ continue
217
+
218
+ if thread_id is None:
219
+ raise _CodexFailure("event_order", "Codex lifecycle event precedes thread identity")
220
+
221
+ if event_type == "turn.started":
222
+ if set(raw) != {"type"} or turn_started or terminal:
223
+ raise _CodexFailure(
224
+ "unsupported_turn_sequence",
225
+ "Codex capture turn sequence is unsupported",
226
+ )
227
+ turn_started = True
228
+ continue
229
+
230
+ if event_type == "turn.completed":
231
+ if not turn_started or terminal or set(raw) != {"type", "usage"}:
232
+ raise _CodexFailure(
233
+ "unsupported_turn_sequence",
234
+ "Codex capture turn sequence is unsupported",
235
+ )
236
+ if not isinstance(raw.get("usage"), dict):
237
+ raise _CodexFailure(
238
+ "malformed_completion",
239
+ "Codex turn completion shape is invalid",
240
+ )
241
+ lifecycle = Lifecycle.DONE
242
+ terminal = True
243
+ continue
244
+
245
+ if event_type == "turn.failed":
246
+ if not turn_started or terminal or "error" not in raw:
247
+ raise _CodexFailure(
248
+ "unsupported_turn_sequence",
249
+ "Codex capture turn sequence is unsupported",
250
+ )
251
+ lifecycle = Lifecycle.FAILED
252
+ terminal = True
253
+ continue
254
+
255
+ if event_type == "error":
256
+ if terminal:
257
+ raise _CodexFailure(
258
+ "unsupported_turn_sequence",
259
+ "Codex capture turn sequence is unsupported",
260
+ )
261
+ lifecycle = Lifecycle.FAILED
262
+ terminal = True
263
+
264
+ if thread_id is None:
265
+ raise _CodexFailure("missing_thread", "Codex capture has no thread identity")
266
+
267
+ adapter_id = f"{ADAPTER_NAME}:{source_id}"
268
+ session = Session(
269
+ id=f"{adapter_id}/{thread_id}",
270
+ adapter_instance_id=adapter_id,
271
+ native_id=thread_id,
272
+ work_id=thread_id,
273
+ lifecycle=lifecycle,
274
+ observed_at=observed_at,
275
+ evidence=(
276
+ Evidence(
277
+ source_kind=ADAPTER_NAME,
278
+ source_id=source_id,
279
+ field_version=FIELD_VERSION,
280
+ observed_at=observed_at,
281
+ ),
282
+ ),
283
+ )
284
+ errors: tuple[CollectionError, ...] = ()
285
+ status = SourceStatus.OK
286
+ detail: str | None = None
287
+ complete = True
288
+ if partial:
289
+ errors = (
290
+ CollectionError(
291
+ code="partial_record",
292
+ message="Codex capture has an incomplete trailing record",
293
+ source_id=source_id,
294
+ ),
295
+ )
296
+ status = SourceStatus.DEGRADED
297
+ detail = "Codex capture has an incomplete trailing record"
298
+ complete = False
299
+
300
+ return CodexObservation(
301
+ sessions=(session,),
302
+ errors=errors,
303
+ health=SourceHealth(
304
+ source_id=source_id,
305
+ status=status,
306
+ detail=detail,
307
+ observed_at=observed_at,
308
+ ),
309
+ complete=complete,
310
+ version=source_version,
311
+ )
312
+ except _CodexFailure as exc:
313
+ return _error(
314
+ source_id,
315
+ exc,
316
+ observed_at,
317
+ version=version,
318
+ )
319
+ except (OSError, TypeError, ValueError):
320
+ return _error(
321
+ source_id,
322
+ _CodexFailure("capture_unavailable", "Codex capture is unavailable"),
323
+ observed_at,
324
+ version=version,
325
+ )