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,80 @@
1
+ """Single ``anyio.run()`` bridge: signals, drain, and exit-code translation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import signal
7
+ from collections.abc import Callable, Coroutine
8
+ from functools import wraps
9
+ from typing import Any
10
+
11
+ import anyio
12
+ import typer
13
+
14
+ from codexloop.application.dto import RunResult
15
+ from codexloop.bootstrap import DrainControl, current_drain, register_drain
16
+ from codexloop.domain.errors import ConfigurationError
17
+
18
+
19
+ def sysexit_for(result: RunResult) -> int:
20
+ if result.reason == "stop":
21
+ return 130
22
+ if result.success:
23
+ return 0
24
+ return 1
25
+
26
+
27
+ def _request_drain() -> None:
28
+ drain = current_drain()
29
+ if drain is not None:
30
+ drain.request_stop()
31
+
32
+
33
+ def _signal_handler(_signum: int, _frame: object | None) -> None:
34
+ _request_drain()
35
+
36
+
37
+ def _install_drain_signals() -> None:
38
+ try:
39
+ loop = asyncio.get_running_loop()
40
+ except RuntimeError:
41
+ signal.signal(signal.SIGINT, _signal_handler)
42
+ signal.signal(signal.SIGTERM, _signal_handler)
43
+ return
44
+ for sig in (signal.SIGINT, signal.SIGTERM):
45
+ try:
46
+ loop.add_signal_handler(sig, _request_drain)
47
+ except (NotImplementedError, RuntimeError):
48
+ signal.signal(sig, _signal_handler)
49
+
50
+
51
+ def _raise_for_result(result: object) -> None:
52
+ if not isinstance(result, RunResult):
53
+ return
54
+ code = sysexit_for(result)
55
+ if code != 0:
56
+ raise typer.Exit(code)
57
+
58
+
59
+ def async_command[**P, T](func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
60
+ """Run an async Typer command via ``anyio.run()`` with SIGINT/SIGTERM drain."""
61
+
62
+ @wraps(func)
63
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
64
+ async def _runner() -> T:
65
+ register_drain(DrainControl())
66
+ _install_drain_signals()
67
+ return await func(*args, **kwargs)
68
+
69
+ try:
70
+ result = anyio.run(_runner)
71
+ except ConfigurationError as exc:
72
+ typer.echo(str(exc), err=True)
73
+ raise typer.Exit(2) from exc
74
+ except KeyboardInterrupt:
75
+ _request_drain()
76
+ raise typer.Exit(130) from None
77
+ _raise_for_result(result)
78
+ return result
79
+
80
+ return wrapper
@@ -0,0 +1 @@
1
+ """CLI command implementations. Must not import ``infrastructure``."""
@@ -0,0 +1,24 @@
1
+ """``codexloop approval`` — enqueue SetApproval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import enqueue_run_control
8
+ from codexloop.domain.approval import ApprovalPolicy
9
+ from codexloop.domain.control import SetApproval
10
+ from codexloop.domain.errors import ConfigurationError
11
+
12
+
13
+ def approval_cmd(
14
+ policy: str = typer.Argument(..., help="Approval policy."),
15
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
16
+ ) -> None:
17
+ """Queue an approval-policy change for the next control boundary."""
18
+ try:
19
+ value = ApprovalPolicy(policy)
20
+ path = enqueue_run_control(SetApproval(policy=value), run_id=run_id)
21
+ except (ConfigurationError, ValueError) as exc:
22
+ typer.echo(str(exc), err=True)
23
+ raise typer.Exit(2) from exc
24
+ typer.echo(f"queued → {path}")
@@ -0,0 +1,33 @@
1
+ """``codexloop capacity`` — print plan windows or say they are unavailable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import read_capacity_windows
8
+
9
+
10
+ def capacity() -> None:
11
+ """Show ChatGPT plan windows when known; say so honestly when not."""
12
+ windows = read_capacity_windows()
13
+ if windows is None:
14
+ typer.echo("plan windows unavailable")
15
+ return
16
+ typer.echo(f"plan_type: {windows.plan_type or 'unknown'}")
17
+ typer.echo(f"limit_reached: {windows.limit_reached or 'none'}")
18
+ if windows.primary is None:
19
+ typer.echo("primary: unavailable")
20
+ else:
21
+ p = windows.primary
22
+ typer.echo(
23
+ f"primary: used={p.used_percent}% window={p.window_minutes}m "
24
+ f"resets_at={p.resets_at.isoformat() if p.resets_at else 'unknown'}"
25
+ )
26
+ if windows.secondary is None:
27
+ typer.echo("secondary: unavailable")
28
+ else:
29
+ s = windows.secondary
30
+ typer.echo(
31
+ f"secondary: used={s.used_percent}% window={s.window_minutes}m "
32
+ f"resets_at={s.resets_at.isoformat() if s.resets_at else 'unknown'}"
33
+ )
@@ -0,0 +1,22 @@
1
+ """``codexloop cwd`` — enqueue SetCwd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import enqueue_run_control
8
+ from codexloop.domain.control import SetCwd
9
+ from codexloop.domain.errors import ConfigurationError
10
+
11
+
12
+ def cwd_cmd(
13
+ path: str = typer.Argument(..., help="Working directory path."),
14
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
15
+ ) -> None:
16
+ """Queue a cwd change for the next control boundary."""
17
+ try:
18
+ written = enqueue_run_control(SetCwd(cwd=path), run_id=run_id)
19
+ except ConfigurationError as exc:
20
+ typer.echo(str(exc), err=True)
21
+ raise typer.Exit(2) from exc
22
+ typer.echo(f"queued → {written}")
@@ -0,0 +1,27 @@
1
+ """``codexloop doctor`` — pre-flight environment checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from codexloop.bootstrap import run_doctor_checks
10
+
11
+
12
+ def doctor(
13
+ cwd: Path | None = typer.Option(None, "--cwd", help="Working directory to check."),
14
+ ) -> None:
15
+ """Report auth mode, probe strategies, and other pre-flight gates."""
16
+ report = run_doctor_checks(cwd=cwd)
17
+ typer.echo(f"auth_mode: {report.auth_mode}")
18
+ strategies = ", ".join(
19
+ f"{name}={'live' if live else 'unavailable'}"
20
+ for name, live in report.probe_strategies.items()
21
+ )
22
+ typer.echo(f"probe_strategies: {strategies}")
23
+ for check in report.checks:
24
+ mark = "ok" if check.passed else "FAIL"
25
+ typer.echo(f"[{mark}] {check.name}: {check.detail}")
26
+ if not report.all_passed:
27
+ raise typer.Exit(1)
@@ -0,0 +1,24 @@
1
+ """``codexloop effort`` — enqueue SetEffort."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import enqueue_run_control
8
+ from codexloop.domain.control import SetEffort
9
+ from codexloop.domain.errors import ConfigurationError
10
+ from codexloop.domain.model_profile import Effort
11
+
12
+
13
+ def effort_cmd(
14
+ effort: str = typer.Argument(..., help="Effort level."),
15
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
16
+ ) -> None:
17
+ """Queue an effort change for the next control boundary."""
18
+ try:
19
+ value = Effort(effort)
20
+ path = enqueue_run_control(SetEffort(effort=value), run_id=run_id)
21
+ except (ConfigurationError, ValueError) as exc:
22
+ typer.echo(str(exc), err=True)
23
+ raise typer.Exit(2) from exc
24
+ typer.echo(f"queued → {path}")
@@ -0,0 +1,15 @@
1
+ """``codexloop logs [run-id]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import read_run_events
8
+ from codexloop.cli.render import render_logs
9
+
10
+
11
+ def logs(
12
+ run_id: str | None = typer.Argument(None, help="Run id. Defaults to the latest run."),
13
+ ) -> None:
14
+ """Print events.jsonl for a run."""
15
+ typer.echo(render_logs(read_run_events(run_id)))
@@ -0,0 +1,22 @@
1
+ """``codexloop model`` — enqueue SetModel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import enqueue_run_control
8
+ from codexloop.domain.control import SetModel
9
+ from codexloop.domain.errors import ConfigurationError
10
+
11
+
12
+ def model_cmd(
13
+ model: str = typer.Argument(..., help="Model name."),
14
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
15
+ ) -> None:
16
+ """Queue a model change for the next control boundary."""
17
+ try:
18
+ path = enqueue_run_control(SetModel(model=model), run_id=run_id)
19
+ except ConfigurationError as exc:
20
+ typer.echo(str(exc), err=True)
21
+ raise typer.Exit(2) from exc
22
+ typer.echo(f"queued → {path}")
@@ -0,0 +1,32 @@
1
+ """``codexloop prompt`` — queue an operator prompt into the run inbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import enqueue_run_control
8
+ from codexloop.domain.control import Prompt, PromptTiming
9
+ from codexloop.domain.errors import ConfigurationError
10
+
11
+
12
+ def prompt(
13
+ text: str = typer.Argument(..., help="Prompt text to queue."),
14
+ now: bool = typer.Option(False, "--now", help="Apply at the next control poll."),
15
+ next_turn: bool = typer.Option(
16
+ False,
17
+ "--next-turn",
18
+ help="Apply before the next turn.",
19
+ ),
20
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
21
+ ) -> None:
22
+ """Queue an operator prompt. Requires exactly one of --now / --next-turn."""
23
+ if now == next_turn:
24
+ typer.echo("Specify exactly one of --now or --next-turn.", err=True)
25
+ raise typer.Exit(2)
26
+ timing = PromptTiming.NOW if now else PromptTiming.NEXT_TURN
27
+ try:
28
+ path = enqueue_run_control(Prompt(text=text, timing=timing), run_id=run_id)
29
+ except ConfigurationError as exc:
30
+ typer.echo(str(exc), err=True)
31
+ raise typer.Exit(2) from exc
32
+ typer.echo(f"queued → {path}")
@@ -0,0 +1,25 @@
1
+ """``codexloop reset`` — create a labeled savepoint of the current tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import create_savepoint
8
+ from codexloop.domain.errors import ConfigurationError
9
+
10
+
11
+ def reset(
12
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
13
+ label: str = typer.Option("reset", "--label", help="Savepoint label."),
14
+ ) -> None:
15
+ """Record a savepoint (commit if dirty, else ref-tag only)."""
16
+ try:
17
+ point = create_savepoint(label=label, run_id=run_id, summary="operator reset")
18
+ except ConfigurationError as exc:
19
+ typer.echo(str(exc), err=True)
20
+ raise typer.Exit(2) from exc
21
+ if point is None:
22
+ typer.echo("not a git repository — no savepoint created")
23
+ raise typer.Exit(1)
24
+ kind = "commit" if point.committed else "ref-only"
25
+ typer.echo(f"savepoint {point.n} ({kind}) {point.sha[:12]}")
@@ -0,0 +1,38 @@
1
+ """``codexloop resume [<thread-id> | --last]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.application.usecases.resume_thread import resume_thread
8
+ from codexloop.application.usecases.run_plan import run_plan
9
+ from codexloop.bootstrap import build_runner
10
+ from codexloop.cli.asyncio import async_command
11
+ from codexloop.cli.render import render_result
12
+ from codexloop.domain.session import MostRecent
13
+
14
+
15
+ @async_command
16
+ async def resume(
17
+ thread_id: str | None = typer.Argument(None, help="Thread id to resume."),
18
+ last: bool = typer.Option(False, "--last", help="Resume the most recent thread."),
19
+ transport: str = typer.Option(
20
+ "exec",
21
+ "--transport",
22
+ help="Agent transport: exec or app-server.",
23
+ ),
24
+ ) -> object:
25
+ """Resume by explicit thread id (default) or the most recent catalog entry."""
26
+ if thread_id is None and not last:
27
+ typer.echo("Specify a thread id or --last.", err=True)
28
+ raise typer.Exit(2)
29
+ if thread_id is not None and last:
30
+ typer.echo("Specify a thread id or --last, not both.", err=True)
31
+ raise typer.Exit(2)
32
+ ctx = build_runner(transport=transport)
33
+ if thread_id is not None:
34
+ result = await resume_thread(ctx, thread_id)
35
+ else:
36
+ result = await run_plan(ctx, MostRecent(), "")
37
+ typer.echo(render_result(result))
38
+ return result
@@ -0,0 +1,50 @@
1
+ """``codexloop run <plan.md>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from codexloop.application.usecases.run_plan import run_plan
10
+ from codexloop.bootstrap import build_runner, events_path_for_run, run_stream_ui_for_events
11
+ from codexloop.cli.asyncio import async_command
12
+ from codexloop.cli.render import render_result
13
+ from codexloop.domain.session import PlanFile
14
+
15
+
16
+ @async_command
17
+ async def run(
18
+ plan: Path = typer.Argument(
19
+ ...,
20
+ exists=True,
21
+ readable=True,
22
+ help="Markdown work plan.",
23
+ ),
24
+ transport: str = typer.Option(
25
+ "exec",
26
+ "--transport",
27
+ help="Agent transport: exec or app-server.",
28
+ ),
29
+ model: str | None = typer.Option(None, "--model", help="Model name."),
30
+ max_turns: int | None = typer.Option(None, "--max-turns", help="Turn budget."),
31
+ max_wait: str | None = typer.Option(None, "--max-wait", help="Max wait duration."),
32
+ stream_ui: bool = typer.Option(
33
+ False,
34
+ "--stream-ui",
35
+ help="Open a Textual live view of the run event log after completion.",
36
+ ),
37
+ ) -> object:
38
+ """Drive a new autonomous run from a markdown work plan."""
39
+ flags: dict[str, object] = {
40
+ "model": model,
41
+ "max_turns": max_turns,
42
+ "max_wait": max_wait,
43
+ }
44
+ ctx = build_runner(transport=transport, flags=flags)
45
+ text = plan.read_text(encoding="utf-8")
46
+ result = await run_plan(ctx, PlanFile(str(plan)), text)
47
+ typer.echo(render_result(result))
48
+ if stream_ui:
49
+ run_stream_ui_for_events(events_path_for_run())
50
+ return result
@@ -0,0 +1,13 @@
1
+ """``codexloop runs``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import list_run_records
8
+ from codexloop.cli.render import render_runs
9
+
10
+
11
+ def runs() -> None:
12
+ """List run directories under .codexloop/runs/."""
13
+ typer.echo(render_runs(list_run_records()))
@@ -0,0 +1,24 @@
1
+ """``codexloop sandbox`` — enqueue SetSandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import enqueue_run_control
8
+ from codexloop.domain.approval import SandboxMode
9
+ from codexloop.domain.control import SetSandbox
10
+ from codexloop.domain.errors import ConfigurationError
11
+
12
+
13
+ def sandbox_cmd(
14
+ sandbox: str = typer.Argument(..., help="Sandbox mode."),
15
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
16
+ ) -> None:
17
+ """Queue a sandbox-mode change for the next control boundary."""
18
+ try:
19
+ value = SandboxMode(sandbox)
20
+ path = enqueue_run_control(SetSandbox(sandbox=value), run_id=run_id)
21
+ except (ConfigurationError, ValueError) as exc:
22
+ typer.echo(str(exc), err=True)
23
+ raise typer.Exit(2) from exc
24
+ typer.echo(f"queued → {path}")
@@ -0,0 +1,25 @@
1
+ """``codexloop savepoints`` — list git save points for a run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import list_savepoints
8
+ from codexloop.domain.errors import ConfigurationError
9
+
10
+
11
+ def savepoints(
12
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
13
+ ) -> None:
14
+ """List numbered git save points."""
15
+ try:
16
+ points = list_savepoints(run_id=run_id)
17
+ except ConfigurationError as exc:
18
+ typer.echo(str(exc), err=True)
19
+ raise typer.Exit(2) from exc
20
+ if not points:
21
+ typer.echo("no savepoints")
22
+ return
23
+ for point in points:
24
+ committed = "commit" if point.committed else "ref-only"
25
+ typer.echo(f"{point.n}\t{point.sha[:12]}\t{committed}\t{point.label}\t{point.ref}")
@@ -0,0 +1,26 @@
1
+ """``codexloop snapshot`` — copy the workspace excluding ``.codexloop/``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import restore_run_snapshot, take_snapshot
8
+ from codexloop.domain.errors import ConfigurationError
9
+
10
+
11
+ def snapshot(
12
+ name: str | None = typer.Argument(None, help="Snapshot name (default: timestamp)."),
13
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
14
+ restore: str | None = typer.Option(None, "--restore", help="Restore a named snapshot."),
15
+ ) -> None:
16
+ """Create or restore a filesystem snapshot for the active run."""
17
+ try:
18
+ if restore is not None:
19
+ restore_run_snapshot(restore, run_id=run_id)
20
+ typer.echo(f"restored snapshot {restore}")
21
+ return
22
+ path = take_snapshot(run_id=run_id, name=name)
23
+ except (ConfigurationError, FileNotFoundError) as exc:
24
+ typer.echo(str(exc), err=True)
25
+ raise typer.Exit(2) from exc
26
+ typer.echo(f"snapshot → {path}")
@@ -0,0 +1,15 @@
1
+ """``codexloop status [run-id]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import read_run_record
8
+ from codexloop.cli.render import render_status
9
+
10
+
11
+ def status(
12
+ run_id: str | None = typer.Argument(None, help="Run id. Defaults to the latest run."),
13
+ ) -> None:
14
+ """Show persisted state for a run."""
15
+ typer.echo(render_status(read_run_record(run_id)))
@@ -0,0 +1,21 @@
1
+ """``codexloop stop`` — enqueue a Stop control command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import enqueue_run_control
8
+ from codexloop.domain.control import Stop
9
+ from codexloop.domain.errors import ConfigurationError
10
+
11
+
12
+ def stop(
13
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
14
+ ) -> None:
15
+ """Request a graceful stop at the next control boundary."""
16
+ try:
17
+ path = enqueue_run_control(Stop(), run_id=run_id)
18
+ except ConfigurationError as exc:
19
+ typer.echo(str(exc), err=True)
20
+ raise typer.Exit(2) from exc
21
+ typer.echo(f"queued stop → {path}")
@@ -0,0 +1,15 @@
1
+ """``codexloop threads`` — this product's run registry, not vendor sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.application.usecases.list_threads import list_threads
8
+ from codexloop.bootstrap import build_runner
9
+ from codexloop.cli.render import render_threads
10
+
11
+
12
+ def threads() -> None:
13
+ """List this product's run registry (not vendor Codex sessions)."""
14
+ ctx = build_runner(ensure_run=False)
15
+ typer.echo(render_threads(list_threads(ctx)))
@@ -0,0 +1,24 @@
1
+ """``codexloop unwind`` — reset the worktree to a save point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from codexloop.bootstrap import unwind_savepoint
8
+ from codexloop.domain.errors import ConfigurationError
9
+
10
+
11
+ def unwind(
12
+ to: str = typer.Argument(..., help="Save point number, sha prefix, or label."),
13
+ run_id: str | None = typer.Option(None, "--run-id", help="Target run id."),
14
+ backup: bool = typer.Option(True, "--backup/--no-backup", help="Keep a backup ref."),
15
+ ) -> None:
16
+ """Hard-reset to a save point. Refuses while a run is live."""
17
+ try:
18
+ result = unwind_savepoint(to, run_id=run_id, backup=backup)
19
+ except (ConfigurationError, ValueError) as exc:
20
+ typer.echo(str(exc), err=True)
21
+ raise typer.Exit(2) from exc
22
+ typer.echo(f"restored {result.restored_sha} (savepoint {result.to.n})")
23
+ if result.backup_ref:
24
+ typer.echo(f"backup {result.backup_ref}")
@@ -0,0 +1,66 @@
1
+ """``codexloop watch`` — print current run state (one-shot or continuous)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+
8
+ import typer
9
+
10
+ from codexloop.bootstrap import (
11
+ events_path_for_run,
12
+ read_run_state,
13
+ run_is_live,
14
+ run_stream_ui_for_events,
15
+ )
16
+
17
+
18
+ def watch(
19
+ run_id: str | None = typer.Argument(None, help="Run id. Defaults to the latest run."),
20
+ replay: bool = typer.Option(
21
+ False,
22
+ "--replay",
23
+ help="Open the Textual stream UI against the run event log.",
24
+ ),
25
+ follow: bool = typer.Option(
26
+ False,
27
+ "--follow",
28
+ "-f",
29
+ help="Keep printing state whenever it changes until the run exits.",
30
+ ),
31
+ interval: float = typer.Option(
32
+ 1.0,
33
+ "--interval",
34
+ min=0.1,
35
+ help="Poll interval in seconds when --follow is set.",
36
+ ),
37
+ ) -> None:
38
+ """Show a snapshot of persisted run state."""
39
+ if replay:
40
+ run_stream_ui_for_events(events_path_for_run(run_id))
41
+ return
42
+ if follow:
43
+ _follow(run_id, interval=interval)
44
+ return
45
+ state = read_run_state(run_id)
46
+ if not state:
47
+ typer.echo("no run state")
48
+ raise typer.Exit(1)
49
+ typer.echo(json.dumps(state, indent=2, default=str))
50
+
51
+
52
+ def _follow(run_id: str | None, *, interval: float) -> None:
53
+ last: dict[str, object] | None = None
54
+ saw_state = False
55
+ while True:
56
+ state = read_run_state(run_id)
57
+ if state and state != last:
58
+ typer.echo(json.dumps(state, indent=2, default=str))
59
+ last = state
60
+ saw_state = True
61
+ if saw_state and not run_is_live(run_id):
62
+ return
63
+ if not saw_state and not state:
64
+ typer.echo("no run state")
65
+ raise typer.Exit(1)
66
+ time.sleep(interval)