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,39 @@
1
+ """Human-readable rendering of CLI results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ from codexloop.application.dto import RunResult
9
+ from codexloop.domain.session import ThreadRef
10
+
11
+
12
+ def render_result(result: RunResult) -> str:
13
+ status = "done" if result.success else result.reason
14
+ thread = result.thread_id or "-"
15
+ return f"{status} turns={result.turns} thread={thread}"
16
+
17
+
18
+ def render_threads(threads: Sequence[ThreadRef]) -> str:
19
+ if not threads:
20
+ return "No threads recorded."
21
+ return "\n".join(
22
+ f"{ref.thread_id}\t{ref.model}\t{ref.cwd}\t{ref.started_at.isoformat()}" for ref in threads
23
+ )
24
+
25
+
26
+ def render_runs(records: Sequence[Mapping[str, object]]) -> str:
27
+ if not records:
28
+ return "No runs."
29
+ return "\n".join(str(record.get("run_id", "-")) for record in records)
30
+
31
+
32
+ def render_status(record: Mapping[str, object] | None) -> str:
33
+ if record is None:
34
+ return "No runs."
35
+ return json.dumps(dict(record), indent=2, default=str)
36
+
37
+
38
+ def render_logs(text: str) -> str:
39
+ return text if text else "No logs."
@@ -0,0 +1,26 @@
1
+ """Pure domain layer — stdlib only."""
2
+
3
+ from codexloop.domain.error_codes import ErrorClass, classify_code
4
+ from codexloop.domain.errors import (
5
+ AuthError,
6
+ BudgetExceeded,
7
+ CapacityError,
8
+ CodexBinaryError,
9
+ CodexloopError,
10
+ CodexProtocolError,
11
+ ConfigurationError,
12
+ WaitDeadlineExceeded,
13
+ )
14
+
15
+ __all__ = [
16
+ "AuthError",
17
+ "BudgetExceeded",
18
+ "CapacityError",
19
+ "CodexBinaryError",
20
+ "CodexloopError",
21
+ "CodexProtocolError",
22
+ "ConfigurationError",
23
+ "ErrorClass",
24
+ "WaitDeadlineExceeded",
25
+ "classify_code",
26
+ ]
@@ -0,0 +1,38 @@
1
+ """Approval policy × sandbox mode. Defaults never wait and stay in-workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from codexloop.domain.errors import ConfigurationError
8
+
9
+
10
+ class ApprovalPolicy(StrEnum):
11
+ NEVER = "never"
12
+ ON_REQUEST = "on-request"
13
+ ON_FAILURE = "on-failure"
14
+ UNTRUSTED = "untrusted"
15
+
16
+
17
+ class SandboxMode(StrEnum):
18
+ READ_ONLY = "read-only"
19
+ WORKSPACE_WRITE = "workspace-write"
20
+ DANGER_FULL_ACCESS = "danger-full-access"
21
+
22
+
23
+ DEFAULT_APPROVAL = ApprovalPolicy.NEVER
24
+ DEFAULT_SANDBOX = SandboxMode.WORKSPACE_WRITE
25
+
26
+
27
+ def validate(
28
+ policy: ApprovalPolicy,
29
+ sandbox: SandboxMode,
30
+ *,
31
+ allow_dangerous: bool = False,
32
+ ) -> None:
33
+ """Raise unless ``danger-full-access`` is paired with ``allow_dangerous=True``."""
34
+ if sandbox is SandboxMode.DANGER_FULL_ACCESS and not allow_dangerous:
35
+ raise ConfigurationError(
36
+ f"sandbox mode {sandbox.value} requires allow_dangerous=True"
37
+ f" (approval_policy={policy.value})"
38
+ )
@@ -0,0 +1,34 @@
1
+ """Pure exponential backoff with injected jitter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+ from datetime import timedelta
8
+
9
+
10
+ def backoff(
11
+ attempt: int,
12
+ *,
13
+ base: timedelta,
14
+ ceiling: timedelta,
15
+ jitter_ratio: float,
16
+ rand: Callable[[], float],
17
+ ) -> timedelta:
18
+ """Return ``min(ceiling, base * 2^attempt)`` scaled by jitter from ``rand``.
19
+
20
+ ``rand`` must return a float in ``[0, 1]``. Jitter maps that onto
21
+ ``[1 - jitter_ratio, 1 + jitter_ratio]``. The result is never negative and
22
+ never exceeds ``ceiling``.
23
+ """
24
+ ceiling_s = max(ceiling.total_seconds(), 0.0)
25
+ base_s = max(base.total_seconds(), 0.0)
26
+ try:
27
+ exponential = math.ldexp(base_s, attempt)
28
+ except (OverflowError, ValueError):
29
+ exponential = math.inf
30
+ delay_s = min(ceiling_s, exponential)
31
+ factor = (1.0 - jitter_ratio) + (2.0 * jitter_ratio * rand())
32
+ jittered = delay_s * factor
33
+ clamped = min(max(jittered, 0.0), ceiling_s)
34
+ return timedelta(seconds=clamped)
@@ -0,0 +1,56 @@
1
+ """Turn / dollar / wall-clock budgets and a monotonic ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import timedelta
7
+
8
+ from codexloop.domain.errors import ConfigurationError
9
+
10
+ _ZERO = timedelta(0)
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Budget:
15
+ max_turns: int | None
16
+ max_dollars: float | None
17
+ max_wall_clock: timedelta | None
18
+
19
+
20
+ class BudgetLedger:
21
+ """Accumulates usage against a :class:`Budget`. Counters never decrease."""
22
+
23
+ def __init__(self, budget: Budget) -> None:
24
+ self._budget = budget
25
+ self._turns = 0
26
+ self._dollars = 0.0
27
+ self._elapsed = _ZERO
28
+
29
+ @property
30
+ def turns(self) -> int:
31
+ return self._turns
32
+
33
+ @property
34
+ def dollars(self) -> float:
35
+ return self._dollars
36
+
37
+ @property
38
+ def elapsed(self) -> timedelta:
39
+ return self._elapsed
40
+
41
+ def record(self, turns: int = 0, dollars: float = 0.0, elapsed: timedelta = _ZERO) -> None:
42
+ if turns < 0 or dollars < 0 or elapsed < _ZERO:
43
+ raise ConfigurationError("budget ledger cannot decrease")
44
+ self._turns += turns
45
+ self._dollars += dollars
46
+ self._elapsed += elapsed
47
+
48
+ def exceeded(self) -> str | None:
49
+ budget = self._budget
50
+ if budget.max_turns is not None and self._turns >= budget.max_turns:
51
+ return "turns"
52
+ if budget.max_dollars is not None and self._dollars >= budget.max_dollars:
53
+ return "dollars"
54
+ if budget.max_wall_clock is not None and self._elapsed >= budget.max_wall_clock:
55
+ return "wall_clock"
56
+ return None
@@ -0,0 +1,81 @@
1
+ """Capacity value objects and waitability for plan windows / rate limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta
7
+ from typing import assert_never
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class RateLimitWindow:
12
+ used_percent: float | None
13
+ window_minutes: int
14
+ resets_at: datetime | None
15
+
16
+ @property
17
+ def remaining_percent(self) -> float | None:
18
+ if self.used_percent is None:
19
+ return None
20
+ return max(0.0, min(100.0, 100.0 - self.used_percent))
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class PlanWindows:
25
+ primary: RateLimitWindow | None
26
+ secondary: RateLimitWindow | None
27
+ plan_type: str | None
28
+ limit_reached: str | None
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Available:
33
+ plan_windows: PlanWindows | None = None
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ThrottleExhausted:
38
+ retry_after: timedelta | None = None
39
+ aggressive: bool = False
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class WindowExhausted:
44
+ resets_at: datetime | None = None
45
+ window: str = "unknown"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class QuotaExhausted:
50
+ reason: str
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class AuthFailed:
55
+ reason: str
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class TransientBackendError:
60
+ retry_after: timedelta | None = None
61
+
62
+
63
+ CapacityState = (
64
+ Available
65
+ | ThrottleExhausted
66
+ | WindowExhausted
67
+ | QuotaExhausted
68
+ | AuthFailed
69
+ | TransientBackendError
70
+ )
71
+
72
+
73
+ def is_waitable(state: CapacityState) -> bool:
74
+ """Return False only for billing walls and auth failures."""
75
+ match state:
76
+ case QuotaExhausted() | AuthFailed():
77
+ return False
78
+ case Available() | ThrottleExhausted() | WindowExhausted() | TransientBackendError():
79
+ return True
80
+ case _: # pragma: no cover — match is exhaustive over CapacityState
81
+ assert_never(state)
@@ -0,0 +1,98 @@
1
+ """Classify TurnSignals into a CapacityState (body first, status second)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+
7
+ from codexloop.domain.capacity import (
8
+ AuthFailed,
9
+ Available,
10
+ CapacityState,
11
+ PlanWindows,
12
+ QuotaExhausted,
13
+ ThrottleExhausted,
14
+ TransientBackendError,
15
+ WindowExhausted,
16
+ )
17
+ from codexloop.domain.error_codes import ErrorClass, classify_code
18
+ from codexloop.domain.signals import TurnSignals
19
+
20
+
21
+ def classify(signals: TurnSignals) -> CapacityState:
22
+ """Map turn signals to a capacity state using the documented ladder.
23
+
24
+ Completion claims, Retry-After headers, and high ``used_percent`` never
25
+ outrank a body-level billing or auth marker. HTTP 429 is consulted only
26
+ after ``error.code`` / ``error.type``.
27
+ """
28
+ code_cls = classify_code(signals.error_code, None)
29
+ type_cls = classify_code(None, signals.error_type)
30
+ status = signals.http_status
31
+
32
+ if _has(ErrorClass.AUTH, code_cls, type_cls) or status == 401:
33
+ return AuthFailed(reason=_reason(signals, ErrorClass.AUTH, "unauthorized"))
34
+
35
+ if _has(ErrorClass.QUOTA, code_cls, type_cls):
36
+ return QuotaExhausted(reason=_reason(signals, ErrorClass.QUOTA, "quota"))
37
+
38
+ if _has(ErrorClass.WINDOW, code_cls, type_cls):
39
+ resets_at, window = _window_from_plan(signals.plan_windows)
40
+ return WindowExhausted(resets_at=resets_at, window=window)
41
+
42
+ if _has(ErrorClass.THROTTLE, code_cls, type_cls):
43
+ return ThrottleExhausted(
44
+ retry_after=_retry_after(signals.retry_after_s),
45
+ aggressive=_is_slow_down(signals),
46
+ )
47
+
48
+ if _has(ErrorClass.TRANSIENT, code_cls, type_cls):
49
+ return TransientBackendError(retry_after=_retry_after(signals.retry_after_s))
50
+
51
+ if _has(ErrorClass.FATAL, code_cls, type_cls):
52
+ return Available(plan_windows=signals.plan_windows)
53
+
54
+ if status is not None and 500 <= status <= 599:
55
+ return TransientBackendError(retry_after=_retry_after(signals.retry_after_s))
56
+
57
+ if status == 429:
58
+ return WindowExhausted(resets_at=None, window="unknown")
59
+
60
+ return Available(plan_windows=signals.plan_windows)
61
+
62
+
63
+ def _has(target: ErrorClass, code_cls: ErrorClass, type_cls: ErrorClass) -> bool:
64
+ return target is code_cls or target is type_cls
65
+
66
+
67
+ def _reason(signals: TurnSignals, wanted: ErrorClass, fallback: str) -> str:
68
+ if classify_code(signals.error_code, None) is wanted and signals.error_code is not None:
69
+ return signals.error_code
70
+ if classify_code(None, signals.error_type) is wanted and signals.error_type is not None:
71
+ return signals.error_type
72
+ if signals.error_code is not None:
73
+ return signals.error_code
74
+ if signals.error_type is not None:
75
+ return signals.error_type
76
+ return fallback
77
+
78
+
79
+ def _retry_after(seconds: float | None) -> timedelta | None:
80
+ if seconds is None:
81
+ return None
82
+ return timedelta(seconds=seconds)
83
+
84
+
85
+ def _is_slow_down(signals: TurnSignals) -> bool:
86
+ return signals.error_code == "slow_down" or signals.error_type == "slow_down"
87
+
88
+
89
+ def _window_from_plan(plan: PlanWindows | None) -> tuple[datetime | None, str]:
90
+ if plan is None:
91
+ return None, "unknown"
92
+ primary = plan.primary
93
+ if primary is not None and primary.resets_at is not None:
94
+ return primary.resets_at, "five_hour"
95
+ secondary = plan.secondary
96
+ if secondary is not None and secondary.resets_at is not None:
97
+ return secondary.resets_at, "weekly"
98
+ return None, "unknown"
@@ -0,0 +1,130 @@
1
+ """Three-layer completion evaluation: structured output → marker → continue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import assert_never
9
+
10
+ from codexloop.domain.capacity import (
11
+ AuthFailed,
12
+ Available,
13
+ CapacityState,
14
+ QuotaExhausted,
15
+ ThrottleExhausted,
16
+ TransientBackendError,
17
+ WindowExhausted,
18
+ )
19
+ from codexloop.domain.signals import TurnSignals
20
+
21
+ DEFAULT_DONE_MARKER = "CODEXLOOP_TASK_FULLY_COMPLETE"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Done:
26
+ pass
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Continue:
31
+ remaining: list[str]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Blocked:
36
+ reason: str
37
+
38
+
39
+ CompletionVerdict = Done | Continue | Blocked
40
+
41
+
42
+ class CompletionEvaluator:
43
+ """Map turn signals + capacity into a completion verdict.
44
+
45
+ Capacity rejection always outranks a completion claim. Within an
46
+ ``Available`` turn the layers are: structured output, then a done-marker
47
+ line in the final message, then ``Continue``.
48
+ """
49
+
50
+ def __init__(self, done_marker: str = DEFAULT_DONE_MARKER) -> None:
51
+ self._done_marker = done_marker
52
+
53
+ def evaluate(self, signals: TurnSignals, capacity: CapacityState) -> CompletionVerdict:
54
+ parsed = _parse_structured(signals.structured_output)
55
+
56
+ if not _is_available(capacity):
57
+ return Continue(remaining=_remaining_from(parsed))
58
+
59
+ if parsed is not None:
60
+ structured = _verdict_from_structured(parsed)
61
+ if structured is not None:
62
+ return structured
63
+
64
+ if _marker_on_own_line(signals.final_message, self._done_marker):
65
+ return Done()
66
+
67
+ return Continue(remaining=[])
68
+
69
+
70
+ def _is_available(capacity: CapacityState) -> bool:
71
+ match capacity:
72
+ case Available():
73
+ return True
74
+ case (
75
+ ThrottleExhausted()
76
+ | WindowExhausted()
77
+ | QuotaExhausted()
78
+ | AuthFailed()
79
+ | TransientBackendError()
80
+ ):
81
+ return False
82
+ case _: # pragma: no cover — match is exhaustive over CapacityState
83
+ assert_never(capacity)
84
+
85
+
86
+ def _parse_structured(value: object | None) -> Mapping[str, object] | None:
87
+ if value is None:
88
+ return None
89
+ if isinstance(value, Mapping):
90
+ return value
91
+ if isinstance(value, str):
92
+ try:
93
+ loaded = json.loads(value)
94
+ except json.JSONDecodeError:
95
+ return None
96
+ if isinstance(loaded, Mapping):
97
+ return loaded
98
+ return None
99
+ return None
100
+
101
+
102
+ def _verdict_from_structured(parsed: Mapping[str, object]) -> CompletionVerdict | None:
103
+ blocked_on = parsed.get("blocked_on")
104
+ if blocked_on:
105
+ return Blocked(reason=str(blocked_on))
106
+
107
+ remaining = _remaining_from(parsed)
108
+ complete = parsed.get("complete")
109
+ if complete is True:
110
+ if not remaining:
111
+ return Done()
112
+ return Continue(remaining=remaining)
113
+ if complete is False:
114
+ return Continue(remaining=remaining)
115
+ return None
116
+
117
+
118
+ def _remaining_from(parsed: Mapping[str, object] | None) -> list[str]:
119
+ if parsed is None:
120
+ return []
121
+ raw = parsed.get("remaining_work")
122
+ if not isinstance(raw, list):
123
+ return []
124
+ return [str(item) for item in raw]
125
+
126
+
127
+ def _marker_on_own_line(message: str | None, done_marker: str) -> bool:
128
+ if message is None:
129
+ return False
130
+ return any(line.strip() == done_marker for line in message.splitlines())
@@ -0,0 +1,167 @@
1
+ """Operator inbox commands: parse a JSON dict, never ignore unknown kinds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from typing import cast
9
+
10
+ from codexloop.domain.approval import ApprovalPolicy, SandboxMode
11
+ from codexloop.domain.errors import ConfigurationError
12
+ from codexloop.domain.model_profile import Effort
13
+
14
+
15
+ class PromptTiming(StrEnum):
16
+ NOW = "now"
17
+ NEXT_TURN = "next_turn"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Stop:
22
+ def to_dict(self) -> dict[str, object]:
23
+ return {"kind": "stop"}
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Prompt:
28
+ text: str
29
+ timing: PromptTiming
30
+
31
+ def to_dict(self) -> dict[str, object]:
32
+ return {"kind": "prompt", "text": self.text, "timing": self.timing.value}
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class SetModel:
37
+ model: str
38
+
39
+ def to_dict(self) -> dict[str, object]:
40
+ return {"kind": "set_model", "model": self.model}
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class SetEffort:
45
+ effort: Effort
46
+
47
+ def to_dict(self) -> dict[str, object]:
48
+ return {"kind": "set_effort", "effort": self.effort.value}
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class SetApproval:
53
+ policy: ApprovalPolicy
54
+
55
+ def to_dict(self) -> dict[str, object]:
56
+ return {"kind": "set_approval", "policy": self.policy.value}
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class SetSandbox:
61
+ sandbox: SandboxMode
62
+
63
+ def to_dict(self) -> dict[str, object]:
64
+ return {"kind": "set_sandbox", "sandbox": self.sandbox.value}
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class SetCwd:
69
+ cwd: str
70
+
71
+ def to_dict(self) -> dict[str, object]:
72
+ return {"kind": "set_cwd", "cwd": self.cwd}
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Snapshot:
77
+ def to_dict(self) -> dict[str, object]:
78
+ return {"kind": "snapshot"}
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class ResourceMutate:
83
+ payload: dict[str, object]
84
+
85
+ def __post_init__(self) -> None:
86
+ object.__setattr__(self, "payload", dict(self.payload))
87
+
88
+ def to_dict(self) -> dict[str, object]:
89
+ return {"kind": "resource_mutate", "payload": dict(self.payload)}
90
+
91
+
92
+ ControlCommand = (
93
+ Stop
94
+ | Prompt
95
+ | SetModel
96
+ | SetEffort
97
+ | SetApproval
98
+ | SetSandbox
99
+ | SetCwd
100
+ | Snapshot
101
+ | ResourceMutate
102
+ )
103
+
104
+
105
+ def parse_control(data: Mapping[str, object]) -> ControlCommand:
106
+ kind = data.get("kind")
107
+ if not isinstance(kind, str):
108
+ raise ConfigurationError("control command missing kind")
109
+ builder = _BUILDERS.get(kind)
110
+ if builder is None:
111
+ raise ConfigurationError(f"unknown control command kind: {kind!r}")
112
+ try:
113
+ return builder(data)
114
+ except (KeyError, TypeError, ValueError) as exc:
115
+ raise ConfigurationError(f"invalid control command {kind!r}: {exc}") from exc
116
+
117
+
118
+ def _parse_stop(_data: Mapping[str, object]) -> Stop:
119
+ return Stop()
120
+
121
+
122
+ def _parse_prompt(data: Mapping[str, object]) -> Prompt:
123
+ return Prompt(text=str(data["text"]), timing=PromptTiming(str(data["timing"])))
124
+
125
+
126
+ def _parse_set_model(data: Mapping[str, object]) -> SetModel:
127
+ return SetModel(model=str(data["model"]))
128
+
129
+
130
+ def _parse_set_effort(data: Mapping[str, object]) -> SetEffort:
131
+ return SetEffort(effort=Effort(str(data["effort"])))
132
+
133
+
134
+ def _parse_set_approval(data: Mapping[str, object]) -> SetApproval:
135
+ return SetApproval(policy=ApprovalPolicy(str(data["policy"])))
136
+
137
+
138
+ def _parse_set_sandbox(data: Mapping[str, object]) -> SetSandbox:
139
+ return SetSandbox(sandbox=SandboxMode(str(data["sandbox"])))
140
+
141
+
142
+ def _parse_set_cwd(data: Mapping[str, object]) -> SetCwd:
143
+ return SetCwd(cwd=str(data["cwd"]))
144
+
145
+
146
+ def _parse_snapshot(_data: Mapping[str, object]) -> Snapshot:
147
+ return Snapshot()
148
+
149
+
150
+ def _parse_resource_mutate(data: Mapping[str, object]) -> ResourceMutate:
151
+ payload = data["payload"]
152
+ if not isinstance(payload, Mapping):
153
+ raise TypeError("resource_mutate payload must be a mapping")
154
+ return ResourceMutate(payload=dict(cast(Mapping[str, object], payload)))
155
+
156
+
157
+ _BUILDERS: dict[str, Callable[[Mapping[str, object]], ControlCommand]] = {
158
+ "stop": _parse_stop,
159
+ "prompt": _parse_prompt,
160
+ "set_model": _parse_set_model,
161
+ "set_effort": _parse_set_effort,
162
+ "set_approval": _parse_set_approval,
163
+ "set_sandbox": _parse_set_sandbox,
164
+ "set_cwd": _parse_set_cwd,
165
+ "snapshot": _parse_snapshot,
166
+ "resource_mutate": _parse_resource_mutate,
167
+ }