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,37 @@
1
+ """Use case: pre-flight doctor checks before a long unattended run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class DoctorCheck:
13
+ name: str
14
+ passed: bool
15
+ detail: str
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class DoctorReport:
20
+ checks: tuple[DoctorCheck, ...]
21
+ auth_mode: str
22
+ probe_strategies: Mapping[str, bool] = field(default_factory=dict)
23
+
24
+ @property
25
+ def all_passed(self) -> bool:
26
+ return all(check.passed for check in self.checks)
27
+
28
+
29
+ class DoctorEnvironment(Protocol):
30
+ def diagnose(self, *, cwd: Path) -> DoctorReport: ...
31
+
32
+
33
+ def run_doctor(env: DoctorEnvironment, *, cwd: Path) -> DoctorReport:
34
+ return env.diagnose(cwd=cwd)
35
+
36
+
37
+ __all__ = ["DoctorCheck", "DoctorEnvironment", "DoctorReport", "run_doctor"]
@@ -0,0 +1,14 @@
1
+ """Use case: list this product's run registry, not vendor sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from codexloop.application.runner import RunnerContext
8
+ from codexloop.domain.session import ThreadRef
9
+
10
+
11
+ def list_threads(ctx: RunnerContext) -> Sequence[ThreadRef]:
12
+ if ctx.catalog is None:
13
+ return ()
14
+ return ctx.catalog.list_threads()
@@ -0,0 +1,10 @@
1
+ """Use case: probe capacity without spending a real turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codexloop.application.dto import ProbeResult
6
+ from codexloop.application.runner import RunnerContext
7
+
8
+
9
+ async def preflight(ctx: RunnerContext) -> ProbeResult:
10
+ return await ctx.probe.probe()
@@ -0,0 +1,11 @@
1
+ """Use case: resume an existing thread by explicit id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codexloop.application.dto import RunResult
6
+ from codexloop.application.runner import AutonomousRunner, RunnerContext
7
+ from codexloop.domain.session import Explicit
8
+
9
+
10
+ async def resume_thread(ctx: RunnerContext, thread_id: str, plan: str = "") -> RunResult:
11
+ return await AutonomousRunner(ctx).run(Explicit(thread_id), plan)
@@ -0,0 +1,20 @@
1
+ """Use cases: enqueue operator control commands via a port."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ from codexloop.domain.control import ControlCommand
9
+
10
+
11
+ class ControlInbox(Protocol):
12
+ def enqueue(self, command: ControlCommand) -> Path: ...
13
+
14
+
15
+ def enqueue_control(inbox: ControlInbox, command: ControlCommand) -> Path:
16
+ """Enqueue ``command`` and return the path written."""
17
+ return inbox.enqueue(command)
18
+
19
+
20
+ __all__ = ["ControlInbox", "enqueue_control"]
@@ -0,0 +1,11 @@
1
+ """Use case: drive a new run from a markdown work plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codexloop.application.dto import RunResult
6
+ from codexloop.application.runner import AutonomousRunner, RunnerContext
7
+ from codexloop.domain.session import SessionSelector
8
+
9
+
10
+ async def run_plan(ctx: RunnerContext, selector: SessionSelector, plan: str) -> RunResult:
11
+ return await AutonomousRunner(ctx).run(selector, plan)
codexloop/bootstrap.py ADDED
@@ -0,0 +1,461 @@
1
+ """Composition root — the only module permitted to import every onion layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from datetime import UTC, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import anyio
14
+
15
+ from codexloop.application.ports import AgentGateway, CapacityProbe
16
+ from codexloop.application.runner import RunnerContext
17
+ from codexloop.application.usecases.doctor import DoctorReport, run_doctor
18
+ from codexloop.application.usecases.run_control import enqueue_control
19
+ from codexloop.domain.budget import Budget
20
+ from codexloop.domain.capacity import PlanWindows
21
+ from codexloop.domain.control import ControlCommand, Stop
22
+ from codexloop.domain.errors import ConfigurationError
23
+ from codexloop.domain.savepoint import SavePointRef, UnwindResult
24
+ from codexloop.domain.session import ThreadRef
25
+ from codexloop.domain.waiting import AdaptiveWaitPolicy, WaitConfig
26
+ from codexloop.infrastructure.agent.argv import ExecOpts
27
+ from codexloop.infrastructure.agent.gateway import CodexExecGateway
28
+ from codexloop.infrastructure.agent.probe import ExecCapacityProbe
29
+ from codexloop.infrastructure.agent.scripted import resolve_test_agent_from_env
30
+ from codexloop.infrastructure.api.binder import build_api_typer_app as _build_api_typer_app
31
+ from codexloop.infrastructure.appserver.client import AppServerClient
32
+ from codexloop.infrastructure.appserver.gateway import probe_app_server_transport
33
+ from codexloop.infrastructure.capacity_probe import CompositeCapacityProbe
34
+ from codexloop.infrastructure.clock import AnyioSleeper, SystemClock
35
+ from codexloop.infrastructure.config import RunnerConfig, load_config
36
+ from codexloop.infrastructure.control import CompositeRunControl, FileRunControl
37
+ from codexloop.infrastructure.doctor_env import CodexDoctorEnvironment
38
+ from codexloop.infrastructure.git_savepoints import GitSavePointStore
39
+ from codexloop.infrastructure.lock import AdvisoryFileLock
40
+ from codexloop.infrastructure.logging import configure_logging
41
+ from codexloop.infrastructure.notify import CommandNotifier
42
+ from codexloop.infrastructure.progress import LoggingProgressReporter
43
+ from codexloop.infrastructure.rollout import read_rollout_rate_limits
44
+ from codexloop.infrastructure.rundir import RunDirectory, runs_root_for
45
+ from codexloop.infrastructure.snapshot import create_snapshot, restore_snapshot
46
+ from codexloop.infrastructure.state import FileRunStateStore
47
+ from codexloop.infrastructure.state_bus import read_state
48
+ from codexloop.infrastructure.stream_ui import run_stream_ui
49
+
50
+ __all__ = [
51
+ "DrainControl",
52
+ "RunnerConfig",
53
+ "build_api_typer_app",
54
+ "build_runner",
55
+ "current_drain",
56
+ "create_savepoint",
57
+ "enqueue_run_control",
58
+ "events_path_for_run",
59
+ "list_run_records",
60
+ "list_savepoints",
61
+ "read_capacity_windows",
62
+ "read_run_events",
63
+ "read_run_record",
64
+ "read_run_state",
65
+ "register_drain",
66
+ "restore_run_snapshot",
67
+ "run_doctor_checks",
68
+ "run_is_live",
69
+ "run_stream_ui_for_events",
70
+ "take_snapshot",
71
+ "unwind_savepoint",
72
+ ]
73
+
74
+ _ACTIVE_DRAIN: DrainControl | None = None
75
+
76
+
77
+ class DrainControl:
78
+ """RunControl that surfaces SIGINT/SIGTERM as a domain ``Stop``."""
79
+
80
+ def __init__(self) -> None:
81
+ self._stop = False
82
+
83
+ def request_stop(self) -> None:
84
+ self._stop = True
85
+
86
+ def poll(self) -> Sequence[ControlCommand]:
87
+ if not self._stop:
88
+ return []
89
+ self._stop = False
90
+ return [Stop()]
91
+
92
+
93
+ def current_drain() -> DrainControl | None:
94
+ return _ACTIVE_DRAIN
95
+
96
+
97
+ def register_drain(control: DrainControl | None = None) -> DrainControl:
98
+ """Install the process-wide drain. Pass a control to replace; omit to reuse."""
99
+ global _ACTIVE_DRAIN
100
+ if control is not None:
101
+ _ACTIVE_DRAIN = control
102
+ return control
103
+ if _ACTIVE_DRAIN is None:
104
+ _ACTIVE_DRAIN = DrainControl()
105
+ return _ACTIVE_DRAIN
106
+
107
+
108
+ class _JsonThreadCatalog:
109
+ def __init__(self, path: Path) -> None:
110
+ self._path = path
111
+ self._threads: dict[str, ThreadRef] = {}
112
+ self._load()
113
+
114
+ def _load(self) -> None:
115
+ if not self._path.is_file():
116
+ return
117
+ try:
118
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
119
+ except json.JSONDecodeError:
120
+ return
121
+ if not isinstance(raw, list):
122
+ return
123
+ for item in raw:
124
+ if not isinstance(item, dict):
125
+ continue
126
+ try:
127
+ ref = ThreadRef(
128
+ thread_id=str(item["thread_id"]),
129
+ cwd=str(item["cwd"]),
130
+ started_at=datetime.fromisoformat(str(item["started_at"])),
131
+ model=str(item["model"]),
132
+ )
133
+ except (KeyError, TypeError, ValueError):
134
+ continue
135
+ self._threads[ref.thread_id] = ref
136
+
137
+ def _save(self) -> None:
138
+ self._path.parent.mkdir(parents=True, exist_ok=True)
139
+ payload = [
140
+ {
141
+ "thread_id": ref.thread_id,
142
+ "cwd": ref.cwd,
143
+ "started_at": ref.started_at.isoformat(),
144
+ "model": ref.model,
145
+ }
146
+ for ref in self._threads.values()
147
+ ]
148
+ self._path.write_text(json.dumps(payload) + "\n", encoding="utf-8")
149
+
150
+ def list_threads(self) -> Sequence[ThreadRef]:
151
+ return list(self._threads.values())
152
+
153
+ def get(self, thread_id: str) -> ThreadRef | None:
154
+ return self._threads.get(thread_id)
155
+
156
+ def record(self, ref: ThreadRef) -> None:
157
+ self._threads[ref.thread_id] = ref
158
+ self._save()
159
+
160
+
161
+ def _select_gateway(transport: str, *, cwd: Path, config: RunnerConfig) -> AgentGateway:
162
+ if transport == "app-server":
163
+
164
+ async def _probe() -> tuple[AgentGateway | None, str | None]:
165
+ return await probe_app_server_transport(cwd=cwd)
166
+
167
+ gateway, reason = anyio.run(_probe)
168
+ if gateway is not None:
169
+ return gateway
170
+ if reason:
171
+ print(f"codexloop: {reason}", file=sys.stderr)
172
+ return CodexExecGateway(
173
+ cwd=cwd,
174
+ opts=ExecOpts(prompt="", model=config.model, add_dirs=config.add_dirs),
175
+ )
176
+ if transport != "exec":
177
+ raise ConfigurationError(f"unknown transport {transport!r}")
178
+ return CodexExecGateway(
179
+ cwd=cwd,
180
+ opts=ExecOpts(prompt="", model=config.model, add_dirs=config.add_dirs),
181
+ )
182
+
183
+
184
+ def build_runner(
185
+ config: RunnerConfig | None = None,
186
+ *,
187
+ transport: str = "exec",
188
+ cwd: Path | None = None,
189
+ flags: Mapping[str, object] | None = None,
190
+ ensure_run: bool = True,
191
+ ) -> RunnerContext:
192
+ """Wire ports for one CLI invocation. ``cli/`` must not import infrastructure."""
193
+ cwd = Path.cwd() if cwd is None else cwd
194
+ if config is None:
195
+ config = load_config(cwd=cwd, flags=flags)
196
+
197
+ configure_logging(
198
+ level=config.log_level,
199
+ json_logs=config.json_logs,
200
+ log_file=Path(config.log_file) if config.log_file else None,
201
+ )
202
+
203
+ runs_root = runs_root_for(cwd)
204
+ rundir: RunDirectory | None = RunDirectory.create(runs_root) if ensure_run else None
205
+ clock = SystemClock()
206
+
207
+ def write_artifact(name: str, content: str) -> None:
208
+ if rundir is None:
209
+ return
210
+ (rundir.root / name).write_text(content, encoding="utf-8")
211
+
212
+ app_server = AppServerClient(cwd=cwd)
213
+ gateway: AgentGateway
214
+ probe: CapacityProbe
215
+ scripted = resolve_test_agent_from_env()
216
+ if scripted is not None:
217
+ gateway, probe = scripted
218
+ wait_policy = AdaptiveWaitPolicy(
219
+ WaitConfig(
220
+ jitter_ratio=0.0,
221
+ quota_probe_base=timedelta(milliseconds=50),
222
+ quota_probe_ceiling=timedelta(milliseconds=200),
223
+ window_probe_interval=timedelta(milliseconds=50),
224
+ throttle_ceiling=timedelta(milliseconds=200),
225
+ aggressive_ceiling=timedelta(milliseconds=200),
226
+ transient_ceiling=timedelta(milliseconds=200),
227
+ backoff_base=timedelta(milliseconds=50),
228
+ grace=timedelta(0),
229
+ ),
230
+ rand=lambda: 0.0,
231
+ )
232
+ else:
233
+ gateway = _select_gateway(transport, cwd=cwd, config=config)
234
+ probe = CompositeCapacityProbe(
235
+ ExecCapacityProbe(cwd=cwd),
236
+ app_server=app_server.read_rate_limits,
237
+ rollout=read_rollout_rate_limits,
238
+ )
239
+ wait_policy = AdaptiveWaitPolicy(WaitConfig())
240
+
241
+ drain = register_drain()
242
+ if rundir is not None:
243
+ inbox = FileRunControl(rundir.inbox)
244
+ control: DrainControl | CompositeRunControl = CompositeRunControl(drain, inbox)
245
+ else:
246
+ control = drain
247
+
248
+ return RunnerContext(
249
+ clock=clock,
250
+ sleeper=AnyioSleeper(clock),
251
+ gateway=gateway,
252
+ probe=probe,
253
+ store=FileRunStateStore(runs_root),
254
+ control=control,
255
+ catalog=_JsonThreadCatalog(cwd / ".codexloop" / "threads.json"),
256
+ lock=AdvisoryFileLock(cwd / ".codexloop" / "locks"),
257
+ write_artifact=write_artifact,
258
+ notifier=CommandNotifier(config.notify_command),
259
+ reporter=LoggingProgressReporter(),
260
+ budget=Budget(max_turns=config.max_turns, max_dollars=None, max_wall_clock=None),
261
+ wait_policy=wait_policy,
262
+ max_wait=config.max_wait,
263
+ run_id=rundir.run_id if rundir is not None else "anonymous",
264
+ cwd=str(cwd),
265
+ model=config.model or "codex-default",
266
+ )
267
+
268
+
269
+ def list_run_records(cwd: Path | None = None) -> list[dict[str, Any]]:
270
+ root = runs_root_for(Path.cwd() if cwd is None else cwd)
271
+ if not root.is_dir():
272
+ return []
273
+ return [_record_from_dir(child) for child in sorted(root.iterdir()) if child.is_dir()]
274
+
275
+
276
+ def _latest_run_key(record: dict[str, Any]) -> datetime:
277
+ meta = record.get("meta")
278
+ if isinstance(meta, dict):
279
+ raw = meta.get("started_at")
280
+ if isinstance(raw, str):
281
+ try:
282
+ return datetime.fromisoformat(raw)
283
+ except ValueError:
284
+ pass
285
+ try:
286
+ return datetime.fromtimestamp(Path(str(record["root"])).stat().st_mtime, tz=UTC)
287
+ except OSError:
288
+ return datetime.min.replace(tzinfo=UTC)
289
+
290
+
291
+ def read_run_record(run_id: str | None = None, *, cwd: Path | None = None) -> dict[str, Any] | None:
292
+ records = list_run_records(cwd)
293
+ if not records:
294
+ return None
295
+ if run_id is None:
296
+ return max(records, key=_latest_run_key)
297
+ for record in records:
298
+ if record["run_id"] == run_id:
299
+ return record
300
+ return None
301
+
302
+
303
+ def read_run_events(run_id: str | None = None, *, cwd: Path | None = None) -> str:
304
+ record = read_run_record(run_id, cwd=cwd)
305
+ if record is None:
306
+ return ""
307
+ events_path = Path(str(record["root"])) / "events.jsonl"
308
+ if not events_path.is_file():
309
+ return ""
310
+ return events_path.read_text(encoding="utf-8")
311
+
312
+
313
+ def _record_from_dir(root: Path) -> dict[str, Any]:
314
+ meta: dict[str, Any] = {}
315
+ state: dict[str, Any] = {}
316
+ meta_path = root / "meta.json"
317
+ state_path = root / "state.json"
318
+ if meta_path.is_file():
319
+ loaded = json.loads(meta_path.read_text(encoding="utf-8"))
320
+ if isinstance(loaded, dict):
321
+ meta = loaded
322
+ if state_path.is_file():
323
+ loaded = json.loads(state_path.read_text(encoding="utf-8"))
324
+ if isinstance(loaded, dict):
325
+ state = loaded
326
+ return {"run_id": root.name, "root": str(root), "meta": meta, "state": state}
327
+
328
+
329
+ def _run_directory(run_id: str | None = None, *, cwd: Path | None = None) -> RunDirectory:
330
+ record = read_run_record(run_id, cwd=cwd)
331
+ if record is None:
332
+ raise ConfigurationError("no run found — start one with `codexloop run` first")
333
+ directory = RunDirectory(Path(str(record["root"])))
334
+ directory.ensure_layout()
335
+ return directory
336
+
337
+
338
+ def enqueue_run_control(
339
+ command: ControlCommand,
340
+ *,
341
+ run_id: str | None = None,
342
+ cwd: Path | None = None,
343
+ ) -> Path:
344
+ directory = _run_directory(run_id, cwd=cwd)
345
+ return enqueue_control(FileRunControl(directory.inbox), command)
346
+
347
+
348
+ def run_doctor_checks(*, cwd: Path | None = None) -> DoctorReport:
349
+ root = Path.cwd() if cwd is None else cwd
350
+ env = CodexDoctorEnvironment(
351
+ rollout_live=lambda: (Path.home() / ".codex").is_dir(),
352
+ )
353
+ return run_doctor(env, cwd=root)
354
+
355
+
356
+ def read_capacity_windows(*, cwd: Path | None = None) -> PlanWindows | None:
357
+ del cwd
358
+ return read_rollout_rate_limits()
359
+
360
+
361
+ def read_run_state(run_id: str | None = None, *, cwd: Path | None = None) -> dict[str, object]:
362
+ record = read_run_record(run_id, cwd=cwd)
363
+ if record is None:
364
+ return {}
365
+ return read_state(Path(str(record["root"])) / "state.json")
366
+
367
+
368
+ def run_is_live(run_id: str | None = None, *, cwd: Path | None = None) -> bool:
369
+ record = read_run_record(run_id, cwd=cwd)
370
+ if record is None:
371
+ return False
372
+ meta = record.get("meta")
373
+ if not isinstance(meta, dict):
374
+ return False
375
+ pid = meta.get("pid")
376
+ if not isinstance(pid, int):
377
+ return False
378
+ try:
379
+ os.kill(pid, 0)
380
+ except OSError:
381
+ return False
382
+ return True
383
+
384
+
385
+ def list_savepoints(run_id: str | None = None, *, cwd: Path | None = None) -> list[SavePointRef]:
386
+ root = Path.cwd() if cwd is None else cwd
387
+ directory = _run_directory(run_id, cwd=root)
388
+ store = GitSavePointStore(cwd=root, index_path=directory.savepoints_path)
389
+ return store.list_points(directory.run_id)
390
+
391
+
392
+ def create_savepoint(
393
+ *,
394
+ label: str = "manual",
395
+ run_id: str | None = None,
396
+ cwd: Path | None = None,
397
+ attempt: int | None = None,
398
+ summary: str = "",
399
+ ) -> SavePointRef | None:
400
+ root = Path.cwd() if cwd is None else cwd
401
+ directory = _run_directory(run_id, cwd=root)
402
+ store = GitSavePointStore(cwd=root, index_path=directory.savepoints_path)
403
+ return store.create(
404
+ run_id=directory.run_id,
405
+ label=label,
406
+ attempt=attempt,
407
+ summary=summary,
408
+ )
409
+
410
+
411
+ def unwind_savepoint(
412
+ to: str,
413
+ *,
414
+ run_id: str | None = None,
415
+ cwd: Path | None = None,
416
+ backup: bool = True,
417
+ ) -> UnwindResult:
418
+ root = Path.cwd() if cwd is None else cwd
419
+ directory = _run_directory(run_id, cwd=root)
420
+ if run_is_live(directory.run_id, cwd=root):
421
+ raise ConfigurationError("unwind refuses while a run is live")
422
+ store = GitSavePointStore(cwd=root, index_path=directory.savepoints_path)
423
+ return store.unwind(run_id=directory.run_id, to=to, backup=backup, live=False)
424
+
425
+
426
+ def take_snapshot(
427
+ *,
428
+ run_id: str | None = None,
429
+ cwd: Path | None = None,
430
+ name: str | None = None,
431
+ ) -> Path:
432
+ root = Path.cwd() if cwd is None else cwd
433
+ directory = _run_directory(run_id, cwd=root)
434
+ stamp = name or datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
435
+ dest = directory.snapshots / stamp
436
+ return create_snapshot(cwd=root, dest=dest)
437
+
438
+
439
+ def restore_run_snapshot(
440
+ name: str,
441
+ *,
442
+ run_id: str | None = None,
443
+ cwd: Path | None = None,
444
+ ) -> None:
445
+ root = Path.cwd() if cwd is None else cwd
446
+ directory = _run_directory(run_id, cwd=root)
447
+ restore_snapshot(snapshot=directory.snapshots / name, cwd=root)
448
+
449
+
450
+ def build_api_typer_app() -> Any:
451
+ """Compose the generated ``codexloop api`` Typer sub-app (M4)."""
452
+ return _build_api_typer_app()
453
+
454
+
455
+ def run_stream_ui_for_events(path: Path) -> None:
456
+ """Launch the optional Textual stream UI against an events JSONL file."""
457
+ run_stream_ui(path)
458
+
459
+
460
+ def events_path_for_run(run_id: str | None = None, *, cwd: Path | None = None) -> Path:
461
+ return _run_directory(run_id, cwd=cwd).events_path
@@ -0,0 +1 @@
1
+ """Typer CLI package. Must not import ``infrastructure``."""
codexloop/cli/app.py ADDED
@@ -0,0 +1,94 @@
1
+ """Typer root app and console-script entry point.
2
+
3
+ Registered in pyproject.toml as:
4
+ [project.scripts]
5
+ codexloop = "codexloop.cli.app:main"
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ from codexloop import __version__
13
+ from codexloop.bootstrap import build_api_typer_app
14
+ from codexloop.cli.commands.approval_cmd import approval_cmd
15
+ from codexloop.cli.commands.capacity import capacity
16
+ from codexloop.cli.commands.cwd_cmd import cwd_cmd
17
+ from codexloop.cli.commands.doctor import doctor
18
+ from codexloop.cli.commands.effort_cmd import effort_cmd
19
+ from codexloop.cli.commands.logs import logs
20
+ from codexloop.cli.commands.model_cmd import model_cmd
21
+ from codexloop.cli.commands.prompt import prompt
22
+ from codexloop.cli.commands.reset import reset
23
+ from codexloop.cli.commands.resume import resume
24
+ from codexloop.cli.commands.run import run
25
+ from codexloop.cli.commands.runs import runs
26
+ from codexloop.cli.commands.sandbox_cmd import sandbox_cmd
27
+ from codexloop.cli.commands.savepoints import savepoints
28
+ from codexloop.cli.commands.snapshot import snapshot
29
+ from codexloop.cli.commands.status import status
30
+ from codexloop.cli.commands.stop import stop
31
+ from codexloop.cli.commands.threads import threads
32
+ from codexloop.cli.commands.unwind import unwind
33
+ from codexloop.cli.commands.watch import watch
34
+
35
+ app = typer.Typer(
36
+ name="codexloop",
37
+ help=(
38
+ "Onion-architected, autonomous OpenAI Codex session runner — never "
39
+ "blocks on a human, distinguishes rate limits from exhausted credits, "
40
+ "and resumes safely across usage windows."
41
+ ),
42
+ add_completion=False,
43
+ no_args_is_help=True,
44
+ )
45
+
46
+
47
+ def _version_callback(value: bool) -> None:
48
+ if value:
49
+ typer.echo(f"codexloop {__version__}")
50
+ raise typer.Exit()
51
+
52
+
53
+ @app.callback()
54
+ def main_callback(
55
+ version: bool = typer.Option(
56
+ False,
57
+ "--version",
58
+ callback=_version_callback,
59
+ is_eager=True,
60
+ help="Show the installed codexloop version and exit.",
61
+ ),
62
+ ) -> None:
63
+ del version
64
+
65
+
66
+ app.command()(run)
67
+ app.command()(resume)
68
+ app.command()(threads)
69
+ app.command()(status)
70
+ app.command()(logs)
71
+ app.command()(runs)
72
+ app.command()(prompt)
73
+ app.command()(stop)
74
+ app.command()(capacity)
75
+ app.command()(doctor)
76
+ app.command()(watch)
77
+ app.command()(savepoints)
78
+ app.command()(unwind)
79
+ app.command()(reset)
80
+ app.command()(snapshot)
81
+ app.command("model")(model_cmd)
82
+ app.command("effort")(effort_cmd)
83
+ app.command("approval")(approval_cmd)
84
+ app.command("sandbox")(sandbox_cmd)
85
+ app.command("cwd")(cwd_cmd)
86
+ app.add_typer(build_api_typer_app(), name="api")
87
+
88
+
89
+ def main() -> None: # pragma: no cover — process entrypoint
90
+ app()
91
+
92
+
93
+ if __name__ == "__main__": # pragma: no cover
94
+ main()