codexloop 0.1.0__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 (104) hide show
  1. codexloop/__init__.py +3 -0
  2. codexloop/application/__init__.py +1 -0
  3. codexloop/application/dto.py +41 -0
  4. codexloop/application/ports.py +158 -0
  5. codexloop/application/runner.py +467 -0
  6. codexloop/application/usecases/__init__.py +1 -0
  7. codexloop/application/usecases/doctor.py +37 -0
  8. codexloop/application/usecases/list_threads.py +14 -0
  9. codexloop/application/usecases/preflight.py +10 -0
  10. codexloop/application/usecases/resume_thread.py +11 -0
  11. codexloop/application/usecases/run_control.py +20 -0
  12. codexloop/application/usecases/run_plan.py +11 -0
  13. codexloop/bootstrap.py +461 -0
  14. codexloop/cli/__init__.py +1 -0
  15. codexloop/cli/app.py +94 -0
  16. codexloop/cli/asyncio.py +80 -0
  17. codexloop/cli/commands/__init__.py +1 -0
  18. codexloop/cli/commands/approval_cmd.py +24 -0
  19. codexloop/cli/commands/capacity.py +33 -0
  20. codexloop/cli/commands/cwd_cmd.py +22 -0
  21. codexloop/cli/commands/doctor.py +27 -0
  22. codexloop/cli/commands/effort_cmd.py +24 -0
  23. codexloop/cli/commands/logs.py +15 -0
  24. codexloop/cli/commands/model_cmd.py +22 -0
  25. codexloop/cli/commands/prompt.py +32 -0
  26. codexloop/cli/commands/reset.py +25 -0
  27. codexloop/cli/commands/resume.py +38 -0
  28. codexloop/cli/commands/run.py +50 -0
  29. codexloop/cli/commands/runs.py +13 -0
  30. codexloop/cli/commands/sandbox_cmd.py +24 -0
  31. codexloop/cli/commands/savepoints.py +25 -0
  32. codexloop/cli/commands/snapshot.py +26 -0
  33. codexloop/cli/commands/status.py +15 -0
  34. codexloop/cli/commands/stop.py +21 -0
  35. codexloop/cli/commands/threads.py +15 -0
  36. codexloop/cli/commands/unwind.py +24 -0
  37. codexloop/cli/commands/watch.py +66 -0
  38. codexloop/cli/render.py +39 -0
  39. codexloop/domain/__init__.py +26 -0
  40. codexloop/domain/approval.py +38 -0
  41. codexloop/domain/backoff.py +34 -0
  42. codexloop/domain/budget.py +56 -0
  43. codexloop/domain/capacity.py +81 -0
  44. codexloop/domain/classify.py +98 -0
  45. codexloop/domain/completion.py +130 -0
  46. codexloop/domain/control.py +167 -0
  47. codexloop/domain/error_codes.py +75 -0
  48. codexloop/domain/errors.py +35 -0
  49. codexloop/domain/loop.py +190 -0
  50. codexloop/domain/model_profile.py +30 -0
  51. codexloop/domain/plan.py +38 -0
  52. codexloop/domain/savepoint.py +32 -0
  53. codexloop/domain/savepoint_message.py +56 -0
  54. codexloop/domain/session.py +32 -0
  55. codexloop/domain/signals.py +25 -0
  56. codexloop/domain/waiting.py +120 -0
  57. codexloop/infrastructure/__init__.py +0 -0
  58. codexloop/infrastructure/agent/__init__.py +0 -0
  59. codexloop/infrastructure/agent/argv.py +102 -0
  60. codexloop/infrastructure/agent/events.py +274 -0
  61. codexloop/infrastructure/agent/gateway.py +189 -0
  62. codexloop/infrastructure/agent/probe.py +74 -0
  63. codexloop/infrastructure/agent/process.py +208 -0
  64. codexloop/infrastructure/agent/schema.py +31 -0
  65. codexloop/infrastructure/agent/scripted.py +201 -0
  66. codexloop/infrastructure/agent/translate.py +120 -0
  67. codexloop/infrastructure/api/__init__.py +26 -0
  68. codexloop/infrastructure/api/api_baseline.json +340 -0
  69. codexloop/infrastructure/api/binder.py +170 -0
  70. codexloop/infrastructure/api/gateway.py +142 -0
  71. codexloop/infrastructure/api/introspect.py +248 -0
  72. codexloop/infrastructure/api/json_io.py +26 -0
  73. codexloop/infrastructure/api/params.py +162 -0
  74. codexloop/infrastructure/api/providers.py +70 -0
  75. codexloop/infrastructure/api/registry.py +13 -0
  76. codexloop/infrastructure/appserver/__init__.py +6 -0
  77. codexloop/infrastructure/appserver/client.py +245 -0
  78. codexloop/infrastructure/appserver/gateway.py +437 -0
  79. codexloop/infrastructure/appserver/ratelimits.py +100 -0
  80. codexloop/infrastructure/audit.py +26 -0
  81. codexloop/infrastructure/capacity_probe.py +57 -0
  82. codexloop/infrastructure/clock.py +26 -0
  83. codexloop/infrastructure/config.py +150 -0
  84. codexloop/infrastructure/control.py +89 -0
  85. codexloop/infrastructure/doctor_env.py +239 -0
  86. codexloop/infrastructure/events.py +23 -0
  87. codexloop/infrastructure/git_savepoints.py +176 -0
  88. codexloop/infrastructure/lock.py +88 -0
  89. codexloop/infrastructure/logging.py +124 -0
  90. codexloop/infrastructure/notify.py +27 -0
  91. codexloop/infrastructure/progress.py +14 -0
  92. codexloop/infrastructure/redact.py +52 -0
  93. codexloop/infrastructure/rollout.py +113 -0
  94. codexloop/infrastructure/rundir.py +57 -0
  95. codexloop/infrastructure/snapshot.py +39 -0
  96. codexloop/infrastructure/state.py +32 -0
  97. codexloop/infrastructure/state_bus.py +27 -0
  98. codexloop/infrastructure/stream_ui.py +44 -0
  99. codexloop/py.typed +0 -0
  100. codexloop-0.1.0.dist-info/METADATA +104 -0
  101. codexloop-0.1.0.dist-info/RECORD +104 -0
  102. codexloop-0.1.0.dist-info/WHEEL +4 -0
  103. codexloop-0.1.0.dist-info/entry_points.txt +2 -0
  104. codexloop-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,102 @@
1
+ """Pure Codex CLI argv construction — list building only, no subprocess."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from codexloop.domain.approval import (
8
+ DEFAULT_APPROVAL,
9
+ DEFAULT_SANDBOX,
10
+ ApprovalPolicy,
11
+ SandboxMode,
12
+ )
13
+ from codexloop.domain.model_profile import Effort
14
+
15
+ PROBE_PROMPT = "reply with the single word OK"
16
+
17
+
18
+ def _quote_c_value(value: str) -> str:
19
+ """Wrap ``value`` in double quotes, escaping any internal quotes."""
20
+ return '"' + value.replace('"', '\\"') + '"'
21
+
22
+
23
+ def _c_override(key: str, value: str) -> list[str]:
24
+ return ["-c", f"{key}={_quote_c_value(value)}"]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ExecOpts:
29
+ """Options for ``codex exec`` / ``codex exec resume`` argv construction."""
30
+
31
+ prompt: str
32
+ model: str | None = None
33
+ effort: Effort | None = None
34
+ approval: ApprovalPolicy = DEFAULT_APPROVAL
35
+ sandbox: SandboxMode = DEFAULT_SANDBOX
36
+ add_dirs: tuple[str, ...] = ()
37
+ output_schema: str | None = None
38
+ output_last_message: str | None = None
39
+ skip_git_repo_check: bool = False
40
+
41
+
42
+ def _shared_flags(opts: ExecOpts) -> list[str]:
43
+ """Flags common to exec and resume (after ``--json``)."""
44
+ argv: list[str] = []
45
+ if opts.model is not None:
46
+ argv.extend(["--model", opts.model])
47
+ argv.extend(_c_override("approval_policy", opts.approval.value))
48
+ argv.extend(_c_override("sandbox_mode", opts.sandbox.value))
49
+ if opts.effort is not None:
50
+ argv.extend(_c_override("model_reasoning_effort", opts.effort.value))
51
+ for directory in opts.add_dirs:
52
+ argv.extend(["--add-dir", directory])
53
+ if opts.output_schema is not None:
54
+ argv.extend(["--output-schema", opts.output_schema])
55
+ if opts.output_last_message is not None:
56
+ argv.extend(["--output-last-message", opts.output_last_message])
57
+ if opts.skip_git_repo_check:
58
+ argv.append("--skip-git-repo-check")
59
+ return argv
60
+
61
+
62
+ def build_exec_argv(opts: ExecOpts) -> list[str]:
63
+ """Build ``codex exec … -- <prompt>``."""
64
+ argv: list[str] = ["codex", "exec", "--json"]
65
+ argv.extend(_shared_flags(opts))
66
+ argv.extend(["--", opts.prompt])
67
+ return argv
68
+
69
+
70
+ def build_resume_argv(thread_id: str | None, opts: ExecOpts) -> list[str]:
71
+ """Build ``codex exec resume <id|--last> … -- <prompt>``.
72
+
73
+ Policy is always expressed via ``-c`` overrides — never a bare ``--sandbox``.
74
+ """
75
+ argv: list[str] = ["codex", "exec", "resume"]
76
+ if thread_id is None:
77
+ argv.append("--last")
78
+ else:
79
+ argv.append(thread_id)
80
+ argv.append("--json")
81
+ argv.extend(_shared_flags(opts))
82
+ argv.extend(["--", opts.prompt])
83
+ return argv
84
+
85
+
86
+ def build_probe_argv(opts: ExecOpts) -> list[str]:
87
+ """Build the capacity-probe argv (ephemeral, read-only, fixed prompt).
88
+
89
+ ``opts`` is accepted for API symmetry with exec/resume; probe policy and
90
+ prompt are fixed and do not vary with ``opts``.
91
+ """
92
+ _ = opts
93
+ return [
94
+ "codex",
95
+ "exec",
96
+ "--json",
97
+ "--ephemeral",
98
+ *_c_override("approval_policy", ApprovalPolicy.NEVER.value),
99
+ *_c_override("sandbox_mode", SandboxMode.READ_ONLY.value),
100
+ "--",
101
+ PROBE_PROMPT,
102
+ ]
@@ -0,0 +1,274 @@
1
+ """Forgiving parser for ``codex exec --json`` JSONL events (R3, R4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any
10
+
11
+ from codexloop.domain.capacity import PlanWindows, RateLimitWindow
12
+
13
+ _ERROR_PATHS: tuple[tuple[str, ...], ...] = (
14
+ ("error",),
15
+ ("payload", "error"),
16
+ ("item", "error"),
17
+ ("turn", "error"),
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Usage:
23
+ input_tokens: int | None = None
24
+ cached_input_tokens: int | None = None
25
+ output_tokens: int | None = None
26
+ reasoning_output_tokens: int | None = None
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ErrorPayload:
31
+ code: str | None
32
+ type: str | None
33
+ message: str | None
34
+ status: int | None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ThreadStarted:
39
+ thread_id: str | None
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class TurnStarted:
44
+ pass
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class TurnCompleted:
49
+ usage: Usage | None
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class TurnFailed:
54
+ error: ErrorPayload | None
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class ItemStarted:
59
+ item: Mapping[str, object] | None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ItemCompleted:
64
+ item: Mapping[str, object] | None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class RateLimitsUpdated:
69
+ plan_windows: PlanWindows | None
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class ErrorEvent:
74
+ error: ErrorPayload | None
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class UnknownEvent:
79
+ type: str
80
+
81
+
82
+ CodexEvent = (
83
+ ThreadStarted
84
+ | TurnStarted
85
+ | TurnCompleted
86
+ | TurnFailed
87
+ | ItemStarted
88
+ | ItemCompleted
89
+ | RateLimitsUpdated
90
+ | ErrorEvent
91
+ | UnknownEvent
92
+ )
93
+
94
+
95
+ class JsonlParser:
96
+ """Parse one JSONL line at a time. Unknown and malformed input never raises."""
97
+
98
+ def __init__(self, *, now: datetime | None = None) -> None:
99
+ self._now = now
100
+ self.malformed_count = 0
101
+
102
+ def parse_line(self, line: str) -> CodexEvent | None:
103
+ stripped = line.strip()
104
+ if not stripped:
105
+ return None
106
+ try:
107
+ decoded: object = json.loads(stripped)
108
+ except json.JSONDecodeError:
109
+ self.malformed_count += 1
110
+ return None
111
+ if not isinstance(decoded, dict):
112
+ self.malformed_count += 1
113
+ return None
114
+ return self._parse_obj(decoded)
115
+
116
+ def _parse_obj(self, obj: dict[str, Any]) -> CodexEvent | None:
117
+ event_type = obj.get("type")
118
+ if not isinstance(event_type, str) or not event_type:
119
+ return None
120
+ match event_type:
121
+ case "thread.started":
122
+ return ThreadStarted(thread_id=_opt_str(obj.get("thread_id")))
123
+ case "turn.started":
124
+ return TurnStarted()
125
+ case "turn.completed":
126
+ return TurnCompleted(usage=_usage(obj.get("usage")))
127
+ case "turn.failed":
128
+ return TurnFailed(error=_extract_error(obj))
129
+ case "item.started":
130
+ return ItemStarted(item=_item(obj.get("item")))
131
+ case "item.completed":
132
+ return ItemCompleted(item=_item(obj.get("item")))
133
+ case "rate_limits.updated":
134
+ return RateLimitsUpdated(plan_windows=self._plan_windows(obj))
135
+ case "event_msg":
136
+ payload = obj.get("payload")
137
+ if isinstance(payload, Mapping) and payload.get("type") == "token_count":
138
+ return RateLimitsUpdated(plan_windows=self._plan_windows(obj))
139
+ return UnknownEvent(type=event_type)
140
+ case "error":
141
+ return ErrorEvent(error=_extract_error(obj))
142
+ case _:
143
+ return UnknownEvent(type=event_type)
144
+
145
+ def _plan_windows(self, obj: Mapping[str, Any]) -> PlanWindows | None:
146
+ blob = _rate_limits_blob(obj)
147
+ if blob is None:
148
+ return None
149
+ if not isinstance(blob, Mapping):
150
+ return None
151
+ now = self._now if self._now is not None else datetime.now(UTC)
152
+ return PlanWindows(
153
+ primary=_window(blob.get("primary"), now=now),
154
+ secondary=_window(blob.get("secondary"), now=now),
155
+ plan_type=_opt_str(blob.get("plan_type")),
156
+ limit_reached=_opt_str(blob.get("rate_limit_reached_type")),
157
+ )
158
+
159
+
160
+ def _rate_limits_blob(obj: Mapping[str, Any]) -> object:
161
+ if "rate_limits" in obj:
162
+ return obj["rate_limits"]
163
+ payload = obj.get("payload")
164
+ if isinstance(payload, Mapping) and "rate_limits" in payload:
165
+ return payload["rate_limits"]
166
+ return None
167
+
168
+
169
+ def _window(value: object, *, now: datetime) -> RateLimitWindow | None:
170
+ if not isinstance(value, Mapping):
171
+ return None
172
+ try:
173
+ minutes = _opt_int(value.get("window_minutes"))
174
+ if minutes is None:
175
+ return None
176
+ return RateLimitWindow(
177
+ used_percent=_opt_float(value.get("used_percent")),
178
+ window_minutes=minutes,
179
+ resets_at=_resets_at(value, now=now),
180
+ )
181
+ except (TypeError, ValueError, OverflowError, OSError):
182
+ return None
183
+
184
+
185
+ def _resets_at(window: Mapping[str, Any], *, now: datetime) -> datetime | None:
186
+ raw_at = window.get("resets_at")
187
+ if isinstance(raw_at, bool):
188
+ raw_at = None
189
+ if isinstance(raw_at, int | float):
190
+ try:
191
+ return datetime.fromtimestamp(float(raw_at), tz=UTC)
192
+ except (OSError, OverflowError, ValueError):
193
+ return None
194
+ raw_in = window.get("resets_in_seconds")
195
+ if isinstance(raw_in, bool):
196
+ raw_in = None
197
+ if isinstance(raw_in, int | float):
198
+ try:
199
+ return now + timedelta(seconds=float(raw_in))
200
+ except (OverflowError, ValueError):
201
+ return None
202
+ return None
203
+
204
+
205
+ def _usage(value: object) -> Usage | None:
206
+ if not isinstance(value, Mapping):
207
+ return None
208
+ return Usage(
209
+ input_tokens=_opt_int(value.get("input_tokens")),
210
+ cached_input_tokens=_opt_int(value.get("cached_input_tokens")),
211
+ output_tokens=_opt_int(value.get("output_tokens")),
212
+ reasoning_output_tokens=_opt_int(value.get("reasoning_output_tokens")),
213
+ )
214
+
215
+
216
+ def _item(value: object) -> dict[str, object] | None:
217
+ if not isinstance(value, Mapping):
218
+ return None
219
+ return dict(value)
220
+
221
+
222
+ def _extract_error(obj: Mapping[str, Any]) -> ErrorPayload | None:
223
+ for path in _ERROR_PATHS:
224
+ found = _dig(obj, path)
225
+ if found is None:
226
+ continue
227
+ payload = _error_payload(found)
228
+ if payload is not None:
229
+ return payload
230
+ return None
231
+
232
+
233
+ def _dig(obj: Mapping[str, Any], path: tuple[str, ...]) -> object | None:
234
+ current: object = obj
235
+ for key in path:
236
+ if not isinstance(current, Mapping) or key not in current:
237
+ return None
238
+ current = current[key]
239
+ return current
240
+
241
+
242
+ def _error_payload(value: object) -> ErrorPayload | None:
243
+ if isinstance(value, str):
244
+ return ErrorPayload(code=None, type=None, message=value, status=None)
245
+ if not isinstance(value, Mapping):
246
+ return None
247
+ return ErrorPayload(
248
+ code=_opt_str(value.get("code")),
249
+ type=_opt_str(value.get("type")),
250
+ message=_opt_str(value.get("message")),
251
+ status=_opt_int(value.get("status")),
252
+ )
253
+
254
+
255
+ def _opt_str(value: object) -> str | None:
256
+ if isinstance(value, str):
257
+ return value
258
+ return None
259
+
260
+
261
+ def _opt_int(value: object) -> int | None:
262
+ if isinstance(value, bool):
263
+ return None
264
+ if isinstance(value, int):
265
+ return value
266
+ if isinstance(value, float) and value.is_integer():
267
+ return int(value)
268
+ return None
269
+
270
+
271
+ def _opt_float(value: object) -> float | None:
272
+ if isinstance(value, bool) or not isinstance(value, int | float):
273
+ return None
274
+ return float(value)
@@ -0,0 +1,189 @@
1
+ """``codex exec --json`` adapter implementing :class:`AgentGateway`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import replace
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Protocol, assert_never
12
+
13
+ from codexloop.application.dto import TokenUsage, TurnOutcome
14
+ from codexloop.application.ports import PermissionMode
15
+ from codexloop.domain.approval import ApprovalPolicy, SandboxMode
16
+ from codexloop.domain.model_profile import ModelEffortProfile
17
+ from codexloop.infrastructure.agent.argv import ExecOpts, build_exec_argv, build_resume_argv
18
+ from codexloop.infrastructure.agent.events import CodexEvent, JsonlParser, ThreadStarted, Usage
19
+ from codexloop.infrastructure.agent.process import ProcessResult
20
+ from codexloop.infrastructure.agent.process import run_codex as default_run_codex
21
+ from codexloop.infrastructure.agent.schema import write_output_schema
22
+ from codexloop.infrastructure.agent.translate import to_turn_signals
23
+
24
+ _DEFAULT_TIMEOUT_S = 300.0
25
+ _DEFAULT_MAX_LINE_BYTES = 1_048_576
26
+
27
+
28
+ class RunCodex(Protocol):
29
+ async def __call__(
30
+ self,
31
+ argv: Sequence[str],
32
+ *,
33
+ cwd: str | Path,
34
+ env: Mapping[str, str],
35
+ timeout: float,
36
+ max_line_bytes: int,
37
+ ) -> ProcessResult: ...
38
+
39
+
40
+ class CodexExecGateway:
41
+ """One-shot ``codex exec`` / ``codex exec resume <thread_id>`` session.
42
+
43
+ Records ``thread_id`` from the first ``thread.started`` event and resumes by
44
+ that id thereafter — never ``--last``.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ cwd: str | Path,
51
+ env: Mapping[str, str] | None = None,
52
+ opts: ExecOpts | None = None,
53
+ now: datetime | None = None,
54
+ run_codex: RunCodex | None = None,
55
+ timeout: float = _DEFAULT_TIMEOUT_S,
56
+ max_line_bytes: int = _DEFAULT_MAX_LINE_BYTES,
57
+ ) -> None:
58
+ self._cwd = Path(cwd)
59
+ self._env = dict(env) if env is not None else None
60
+ self._opts = opts if opts is not None else ExecOpts(prompt="")
61
+ self._now = now
62
+ self._run_codex = run_codex if run_codex is not None else default_run_codex
63
+ self._timeout = timeout
64
+ self._max_line_bytes = max_line_bytes
65
+ self._thread_id: str | None = None
66
+ self._closed = False
67
+
68
+ async def send_turn(self, prompt: str) -> TurnOutcome:
69
+ control = self._cwd / ".codexloop"
70
+ control.mkdir(parents=True, exist_ok=True)
71
+ schema_path = write_output_schema(control / "completion.schema.json")
72
+ last_message_path = control / "last-message.json"
73
+ if last_message_path.exists():
74
+ last_message_path.unlink()
75
+ opts = replace(
76
+ self._opts,
77
+ prompt=prompt,
78
+ output_schema=str(schema_path),
79
+ output_last_message=str(last_message_path),
80
+ )
81
+ if self._thread_id is None:
82
+ argv = build_exec_argv(opts)
83
+ else:
84
+ argv = build_resume_argv(self._thread_id, opts)
85
+ env = self._env if self._env is not None else os.environ.copy()
86
+ result = await self._run_codex(
87
+ argv,
88
+ cwd=self._cwd,
89
+ env=env,
90
+ timeout=self._timeout,
91
+ max_line_bytes=self._max_line_bytes,
92
+ )
93
+ events = self._parse_lines(result.stdout_lines)
94
+ self._record_thread_id(events)
95
+ signals = to_turn_signals(
96
+ events,
97
+ exit_code=result.exit_code,
98
+ stderr_tail=result.stderr_tail,
99
+ now=self._now if self._now is not None else datetime.now(UTC),
100
+ )
101
+ structured = _read_structured(last_message_path)
102
+ if structured is not None:
103
+ signals = replace(signals, structured_output=structured)
104
+ return TurnOutcome(
105
+ signals=signals,
106
+ usage=_token_usage(signals.usage),
107
+ exit_code=result.exit_code,
108
+ thread_id=self._thread_id,
109
+ )
110
+
111
+ async def close(self) -> None:
112
+ if self._closed:
113
+ return
114
+ self._closed = True
115
+
116
+ async def set_profile(self, profile: ModelEffortProfile) -> None:
117
+ self._opts = replace(self._opts, model=profile.model, effort=profile.effort)
118
+
119
+ async def set_permission_mode(self, mode: PermissionMode) -> None:
120
+ match mode:
121
+ case PermissionMode.AUTONOMOUS:
122
+ approval, sandbox = ApprovalPolicy.NEVER, SandboxMode.WORKSPACE_WRITE
123
+ case PermissionMode.READ_ONLY:
124
+ approval, sandbox = ApprovalPolicy.NEVER, SandboxMode.READ_ONLY
125
+ case PermissionMode.FULL_ACCESS:
126
+ approval, sandbox = ApprovalPolicy.NEVER, SandboxMode.DANGER_FULL_ACCESS
127
+ case _: # pragma: no cover — exhaustive StrEnum
128
+ assert_never(mode)
129
+ self._opts = replace(self._opts, approval=approval, sandbox=sandbox)
130
+
131
+ async def set_cwd(self, path: str) -> None:
132
+ self._cwd = Path(path)
133
+
134
+ async def set_session_resources(self, resources: Mapping[str, object]) -> None:
135
+ updates: dict[str, object] = {}
136
+ add_dirs = resources.get("add_dirs")
137
+ if isinstance(add_dirs, list | tuple) and all(isinstance(item, str) for item in add_dirs):
138
+ updates["add_dirs"] = tuple(add_dirs)
139
+ approval = resources.get("approval_policy")
140
+ if isinstance(approval, str):
141
+ updates["approval"] = ApprovalPolicy(approval)
142
+ sandbox = resources.get("sandbox_mode")
143
+ if isinstance(sandbox, str):
144
+ updates["sandbox"] = SandboxMode(sandbox)
145
+ if updates:
146
+ self._opts = replace(self._opts, **updates) # type: ignore[arg-type]
147
+
148
+ def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool:
149
+ _ = request_id, reason
150
+ return allow
151
+
152
+ def _parse_lines(self, lines: Sequence[str]) -> list[CodexEvent | None]:
153
+ parser = JsonlParser(now=self._now if self._now is not None else datetime.now(UTC))
154
+ return [parser.parse_line(line) for line in lines]
155
+
156
+ def _record_thread_id(self, events: Sequence[CodexEvent | None]) -> None:
157
+ if self._thread_id is not None:
158
+ return
159
+ for event in events:
160
+ if isinstance(event, ThreadStarted) and event.thread_id:
161
+ self._thread_id = event.thread_id
162
+ return
163
+
164
+
165
+ def _read_structured(path: Path) -> object | None:
166
+ if not path.is_file():
167
+ return None
168
+ try:
169
+ text = path.read_text(encoding="utf-8").strip()
170
+ except OSError:
171
+ return None
172
+ if not text:
173
+ return None
174
+ try:
175
+ parsed: object = json.loads(text)
176
+ except json.JSONDecodeError:
177
+ return text
178
+ return parsed
179
+
180
+
181
+ def _token_usage(usage: object | None) -> TokenUsage | None:
182
+ if not isinstance(usage, Usage):
183
+ return None
184
+ return TokenUsage(
185
+ input_tokens=usage.input_tokens or 0,
186
+ cached_input_tokens=usage.cached_input_tokens or 0,
187
+ output_tokens=usage.output_tokens or 0,
188
+ reasoning_output_tokens=usage.reasoning_output_tokens or 0,
189
+ )
@@ -0,0 +1,74 @@
1
+ """Ephemeral read-only ``codex exec`` capacity probe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping, Sequence
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from codexloop.application.dto import ProbeResult
11
+ from codexloop.domain.capacity import TransientBackendError
12
+ from codexloop.domain.classify import classify
13
+ from codexloop.domain.errors import CodexBinaryError
14
+ from codexloop.infrastructure.agent.argv import ExecOpts, build_probe_argv
15
+ from codexloop.infrastructure.agent.events import CodexEvent, JsonlParser
16
+ from codexloop.infrastructure.agent.gateway import RunCodex
17
+ from codexloop.infrastructure.agent.process import run_codex as default_run_codex
18
+ from codexloop.infrastructure.agent.translate import to_turn_signals
19
+
20
+ _DEFAULT_TIMEOUT_S = 300.0
21
+ _DEFAULT_MAX_LINE_BYTES = 1_048_576
22
+
23
+
24
+ class ExecCapacityProbe:
25
+ """Capacity probe that never resumes and never writes a thread.
26
+
27
+ Always uses :func:`build_probe_argv` (``--ephemeral``, read-only sandbox).
28
+ Spawn failures become :class:`TransientBackendError` rather than raising.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ cwd: str | Path,
35
+ env: Mapping[str, str] | None = None,
36
+ opts: ExecOpts | None = None,
37
+ now: datetime | None = None,
38
+ run_codex: RunCodex | None = None,
39
+ timeout: float = _DEFAULT_TIMEOUT_S,
40
+ max_line_bytes: int = _DEFAULT_MAX_LINE_BYTES,
41
+ ) -> None:
42
+ self._cwd = Path(cwd)
43
+ self._env = dict(env) if env is not None else None
44
+ self._opts = opts if opts is not None else ExecOpts(prompt="")
45
+ self._now = now
46
+ self._run_codex = run_codex if run_codex is not None else default_run_codex
47
+ self._timeout = timeout
48
+ self._max_line_bytes = max_line_bytes
49
+
50
+ async def probe(self) -> ProbeResult:
51
+ argv = build_probe_argv(self._opts)
52
+ env = self._env if self._env is not None else os.environ.copy()
53
+ try:
54
+ result = await self._run_codex(
55
+ argv,
56
+ cwd=self._cwd,
57
+ env=env,
58
+ timeout=self._timeout,
59
+ max_line_bytes=self._max_line_bytes,
60
+ )
61
+ except (OSError, CodexBinaryError):
62
+ return ProbeResult(outcome=TransientBackendError())
63
+ events = self._parse_lines(result.stdout_lines)
64
+ signals = to_turn_signals(
65
+ events,
66
+ exit_code=result.exit_code,
67
+ stderr_tail=result.stderr_tail,
68
+ now=self._now if self._now is not None else datetime.now(UTC),
69
+ )
70
+ return ProbeResult(outcome=classify(signals), snapshot=signals.plan_windows)
71
+
72
+ def _parse_lines(self, lines: Sequence[str]) -> list[CodexEvent | None]:
73
+ parser = JsonlParser(now=self._now if self._now is not None else datetime.now(UTC))
74
+ return [parser.parse_line(line) for line in lines]