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
codexloop/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """codexloop — onion-architected, autonomous OpenAI Codex session runner."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Application layer: ports and use cases. Depends only on domain."""
@@ -0,0 +1,41 @@
1
+ """Application DTOs. ``TurnSignals`` lives in domain and is re-exported here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from codexloop.domain.capacity import CapacityState, PlanWindows
8
+ from codexloop.domain.signals import TurnSignals
9
+
10
+ __all__ = ["ProbeResult", "RunResult", "TokenUsage", "TurnOutcome", "TurnSignals"]
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class TokenUsage:
15
+ input_tokens: int = 0
16
+ cached_input_tokens: int = 0
17
+ output_tokens: int = 0
18
+ reasoning_output_tokens: int = 0
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class TurnOutcome:
23
+ signals: TurnSignals | None = None
24
+ usage: TokenUsage | None = None
25
+ exit_code: int | None = None
26
+ thread_id: str | None = None
27
+ cost_dollars: float = 0.0
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ProbeResult:
32
+ outcome: CapacityState
33
+ snapshot: PlanWindows | None = None
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class RunResult:
38
+ success: bool
39
+ reason: str
40
+ turns: int
41
+ thread_id: str | None
@@ -0,0 +1,158 @@
1
+ """Application ports — Protocols implemented by infrastructure, never imported from it.
2
+
3
+ ``application/`` knows the shape of a collaborator, never a concrete adapter.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable, Mapping, Sequence
9
+ from datetime import datetime
10
+ from enum import StrEnum
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from codexloop.application.dto import ProbeResult, TurnOutcome
14
+ from codexloop.domain.control import ControlCommand
15
+ from codexloop.domain.model_profile import ModelEffortProfile
16
+ from codexloop.domain.session import ThreadRef
17
+
18
+ __all__ = [
19
+ "AgentGateway",
20
+ "ApiGateway",
21
+ "AuditLog",
22
+ "CapacityProbe",
23
+ "Clock",
24
+ "Logger",
25
+ "Notifier",
26
+ "PermissionMode",
27
+ "ProgressReporter",
28
+ "RunControl",
29
+ "RunEventSink",
30
+ "RunResources",
31
+ "RunSnapshotSink",
32
+ "RunStateStore",
33
+ "SavePointStore",
34
+ "SessionLock",
35
+ "Sleeper",
36
+ "StateBus",
37
+ "ThreadCatalog",
38
+ ]
39
+
40
+
41
+ class PermissionMode(StrEnum):
42
+ """Autonomy posture at the application boundary — not Codex CLI flag strings."""
43
+
44
+ AUTONOMOUS = "autonomous"
45
+ READ_ONLY = "read_only"
46
+ FULL_ACCESS = "full_access"
47
+
48
+
49
+ @runtime_checkable
50
+ class Clock(Protocol):
51
+ def now(self) -> datetime: ...
52
+
53
+
54
+ @runtime_checkable
55
+ class Sleeper(Protocol):
56
+ async def sleep_until(self, when: datetime) -> None: ...
57
+
58
+
59
+ @runtime_checkable
60
+ class AgentGateway(Protocol):
61
+ """Vendor-agnostic agent session (exec and app-server adapters implement this)."""
62
+
63
+ async def send_turn(self, prompt: str) -> TurnOutcome: ...
64
+ async def close(self) -> None: ...
65
+ async def set_profile(self, profile: ModelEffortProfile) -> None: ...
66
+ async def set_permission_mode(self, mode: PermissionMode) -> None: ...
67
+ async def set_cwd(self, path: str) -> None: ...
68
+ async def set_session_resources(self, resources: Mapping[str, object]) -> None: ...
69
+ def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool: ...
70
+
71
+
72
+ @runtime_checkable
73
+ class CapacityProbe(Protocol):
74
+ async def probe(self) -> ProbeResult: ...
75
+
76
+
77
+ @runtime_checkable
78
+ class ThreadCatalog(Protocol):
79
+ """Our run registry keyed by ``thread_id``, not vendor session discovery."""
80
+
81
+ def list_threads(self) -> Sequence[ThreadRef]: ...
82
+ def get(self, thread_id: str) -> ThreadRef | None: ...
83
+ def record(self, ref: ThreadRef) -> None: ...
84
+
85
+
86
+ @runtime_checkable
87
+ class ProgressReporter(Protocol):
88
+ def report(self, event: str, **detail: object) -> None: ...
89
+
90
+
91
+ @runtime_checkable
92
+ class AuditLog(Protocol):
93
+ def append(self, event_type: str, payload: Mapping[str, object]) -> None: ...
94
+
95
+
96
+ @runtime_checkable
97
+ class Notifier(Protocol):
98
+ def notify(self, title: str, body: str) -> None: ...
99
+
100
+
101
+ @runtime_checkable
102
+ class Logger(Protocol):
103
+ def bind(self, **kwargs: object) -> Logger: ...
104
+ def info(self, event: str, **kwargs: object) -> None: ...
105
+ def warning(self, event: str, **kwargs: object) -> None: ...
106
+ def error(self, event: str, **kwargs: object) -> None: ...
107
+
108
+
109
+ @runtime_checkable
110
+ class RunStateStore(Protocol):
111
+ def load(self, run_id: str) -> dict[str, object] | None: ...
112
+ def save(self, run_id: str, state: Mapping[str, object]) -> None: ...
113
+
114
+
115
+ @runtime_checkable
116
+ class SessionLock(Protocol):
117
+ def acquire(self, thread_id: str) -> bool: ...
118
+ def release(self, thread_id: str) -> None: ...
119
+
120
+
121
+ @runtime_checkable
122
+ class RunControl(Protocol):
123
+ def poll(self) -> Sequence[ControlCommand]: ...
124
+
125
+
126
+ @runtime_checkable
127
+ class RunEventSink(Protocol):
128
+ def emit(self, event: Mapping[str, object]) -> None: ...
129
+
130
+
131
+ @runtime_checkable
132
+ class StateBus(Protocol):
133
+ def publish(self, event_type: str, payload: Mapping[str, object]) -> None: ...
134
+ def subscribe(self, callback: Callable[[str, Mapping[str, object]], None]) -> None: ...
135
+
136
+
137
+ @runtime_checkable
138
+ class SavePointStore(Protocol):
139
+ def create(self, run_id: str, label: str) -> str: ...
140
+ def list(self, run_id: str) -> Sequence[str]: ...
141
+ def unwind(self, run_id: str, to: str) -> None: ...
142
+
143
+
144
+ @runtime_checkable
145
+ class RunSnapshotSink(Protocol):
146
+ def write(self, snapshot: Mapping[str, object]) -> None: ...
147
+
148
+
149
+ @runtime_checkable
150
+ class ApiGateway(Protocol):
151
+ def invoke(self, method_path: str, **kwargs: object) -> object: ...
152
+
153
+
154
+ @runtime_checkable
155
+ class RunResources(Protocol):
156
+ """Placeholder for run-scoped attachments applied mid-run."""
157
+
158
+ def as_payload(self) -> Mapping[str, object]: ...
@@ -0,0 +1,467 @@
1
+ """AutonomousRunner — executes domain.loop Decisions against application ports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass, field, replace
7
+ from datetime import datetime, timedelta
8
+ from typing import assert_never
9
+
10
+ from codexloop.application.dto import RunResult, TurnOutcome
11
+ from codexloop.application.ports import (
12
+ AgentGateway,
13
+ CapacityProbe,
14
+ Clock,
15
+ Notifier,
16
+ PermissionMode,
17
+ ProgressReporter,
18
+ RunControl,
19
+ RunStateStore,
20
+ SessionLock,
21
+ Sleeper,
22
+ ThreadCatalog,
23
+ )
24
+ from codexloop.domain.approval import ApprovalPolicy, SandboxMode
25
+ from codexloop.domain.budget import Budget, BudgetLedger
26
+ from codexloop.domain.capacity import Available, CapacityState, QuotaExhausted
27
+ from codexloop.domain.classify import classify
28
+ from codexloop.domain.completion import (
29
+ DEFAULT_DONE_MARKER,
30
+ Blocked,
31
+ CompletionEvaluator,
32
+ CompletionVerdict,
33
+ Continue,
34
+ )
35
+ from codexloop.domain.control import (
36
+ ControlCommand,
37
+ Prompt,
38
+ ResourceMutate,
39
+ SetApproval,
40
+ SetCwd,
41
+ SetEffort,
42
+ SetModel,
43
+ SetSandbox,
44
+ Snapshot,
45
+ Stop,
46
+ )
47
+ from codexloop.domain.error_codes import ErrorClass, classify_code
48
+ from codexloop.domain.loop import (
49
+ BackoffUntil,
50
+ Drain,
51
+ Finish,
52
+ LoopOutcome,
53
+ Probe,
54
+ RunLoopStateMachine,
55
+ RunState,
56
+ SendTurn,
57
+ WaitUntil,
58
+ )
59
+ from codexloop.domain.model_profile import Effort, ModelEffortProfile
60
+ from codexloop.domain.plan import WorkPlan
61
+ from codexloop.domain.session import Explicit, MostRecent, PlanFile, SessionSelector, ThreadRef
62
+ from codexloop.domain.signals import TurnSignals
63
+ from codexloop.domain.waiting import AdaptiveWaitPolicy, WaitConfig
64
+
65
+ _FAR_FUTURE = timedelta(days=3650)
66
+ _ZERO = timedelta(0)
67
+
68
+
69
+ def _discard_artifact(name: str, content: str) -> None:
70
+ del name, content
71
+
72
+
73
+ @dataclass
74
+ class RunnerContext:
75
+ """Ports and knobs the runner needs. Unused collaborators may be omitted."""
76
+
77
+ clock: Clock
78
+ sleeper: Sleeper
79
+ gateway: AgentGateway
80
+ probe: CapacityProbe
81
+ store: RunStateStore
82
+ control: RunControl
83
+ catalog: ThreadCatalog | None = None
84
+ lock: SessionLock | None = None
85
+ write_artifact: Callable[[str, str], None] | None = None
86
+ notifier: Notifier | None = None
87
+ reporter: ProgressReporter | None = None
88
+ budget: Budget = field(
89
+ default_factory=lambda: Budget(max_turns=None, max_dollars=None, max_wall_clock=None)
90
+ )
91
+ wait_policy: AdaptiveWaitPolicy | None = None
92
+ max_wait: timedelta | None = None
93
+ run_id: str = "anonymous"
94
+ cwd: str = "."
95
+ model: str = "unknown"
96
+ effort: Effort = Effort.MEDIUM
97
+
98
+
99
+ class AutonomousRunner:
100
+ def __init__(self, ctx: RunnerContext) -> None:
101
+ self._clock = ctx.clock
102
+ self._sleeper = ctx.sleeper
103
+ self._gateway = ctx.gateway
104
+ self._probe = ctx.probe
105
+ self._store = ctx.store
106
+ self._control = ctx.control
107
+ self._catalog = ctx.catalog
108
+ self._lock = ctx.lock
109
+ self._write_artifact = ctx.write_artifact or _discard_artifact
110
+ self._notifier = ctx.notifier
111
+ self._reporter = ctx.reporter
112
+ self._budget = ctx.budget
113
+ self._wait_policy = ctx.wait_policy or AdaptiveWaitPolicy(WaitConfig(), rand=lambda: 0.0)
114
+ self._max_wait = ctx.max_wait
115
+ self._run_id = ctx.run_id
116
+ self._cwd = ctx.cwd
117
+ self._model = ctx.model
118
+ self._effort = ctx.effort
119
+ self._approval = ApprovalPolicy.NEVER
120
+ self._sandbox = SandboxMode.WORKSPACE_WRITE
121
+ self._machine = RunLoopStateMachine()
122
+ self._evaluator = CompletionEvaluator()
123
+ self._ledger = BudgetLedger(ctx.budget)
124
+ self._quota_notified = False
125
+ self._queued_prompt: str | None = None
126
+
127
+ async def run(self, selector: SessionSelector, plan: str) -> RunResult:
128
+ thread_id = self._thread_id_from(selector)
129
+ plan_text, remaining, first_turn = self._restore(thread_id, plan)
130
+ if plan_text:
131
+ parsed = WorkPlan.parse(plan_text)
132
+ if not remaining:
133
+ remaining = list(parsed.remaining_work)
134
+
135
+ locked_id: str | None = None
136
+ if self._lock is not None and thread_id is not None:
137
+ if not self._lock.acquire(thread_id):
138
+ await self._gateway.close()
139
+ return RunResult(success=False, reason="lock", turns=0, thread_id=thread_id)
140
+ locked_id = thread_id
141
+
142
+ state = RunState.Preflight
143
+ outcome = LoopOutcome(capacity=Available())
144
+ started = self._clock.now()
145
+ last_mark = started
146
+ deadline = started + (self._max_wait if self._max_wait is not None else _FAR_FUTURE)
147
+ wait_attempt = 0
148
+
149
+ try:
150
+ while True:
151
+ controls = list(self._control.poll())
152
+ await self._apply_controls(controls)
153
+ now = self._clock.now()
154
+ last_mark = self._record_elapsed(last_mark, now)
155
+
156
+ if state is RunState.Preflight:
157
+ probed = await self._probe.probe()
158
+ outcome = LoopOutcome(
159
+ capacity=probed.outcome,
160
+ deadline_exceeded=now >= deadline,
161
+ )
162
+ else:
163
+ outcome = replace(outcome, deadline_exceeded=now >= deadline)
164
+
165
+ state, decision = self._machine.advance(state, outcome, now, self._ledger, controls)
166
+
167
+ match decision:
168
+ case SendTurn():
169
+ default = plan_text if first_turn else _continuation(remaining)
170
+ prompt = self._take_prompt(default)
171
+ first_turn = False
172
+ turn = await self._gateway.send_turn(prompt)
173
+ if turn.thread_id:
174
+ thread_id = turn.thread_id
175
+ locked_id = self._maybe_lock(thread_id, locked_id)
176
+ last_mark = self._record_elapsed(last_mark, self._clock.now())
177
+ self._ledger.record(turns=1, dollars=turn.cost_dollars)
178
+ capacity, completion = _interpret(turn, self._evaluator)
179
+ self._report_unknown_code(turn)
180
+ if isinstance(completion, Continue) and isinstance(capacity, Available):
181
+ remaining = list(completion.remaining)
182
+ self._persist(
183
+ key=thread_id,
184
+ remaining=remaining,
185
+ first_turn_done=True,
186
+ plan_text=plan_text,
187
+ )
188
+ self._record_thread(thread_id, started)
189
+ wait_attempt = 0
190
+ self._quota_notified = False
191
+ outcome = LoopOutcome(capacity=capacity, completion=completion)
192
+ case Probe():
193
+ probed = await self._probe.probe()
194
+ outcome = LoopOutcome(capacity=probed.outcome)
195
+ case WaitUntil() | BackoffUntil():
196
+ self._notify_quota_once(outcome.capacity)
197
+ until = self._wait_policy.next_probe_at(
198
+ outcome.capacity, self._clock.now(), wait_attempt, deadline
199
+ )
200
+ wait_attempt += 1
201
+ await self._sleeper.sleep_until(until)
202
+ case Drain():
203
+ self._write_stop_summary(thread_id, remaining)
204
+ self._persist(
205
+ key=thread_id,
206
+ remaining=remaining,
207
+ first_turn_done=not first_turn,
208
+ plan_text=plan_text,
209
+ )
210
+ case Finish() as finish:
211
+ self._persist(
212
+ key=thread_id,
213
+ remaining=remaining,
214
+ first_turn_done=not first_turn,
215
+ plan_text=plan_text,
216
+ reason=finish.reason,
217
+ )
218
+ return RunResult(
219
+ success=finish.success,
220
+ reason=finish.reason,
221
+ turns=self._ledger.turns,
222
+ thread_id=thread_id,
223
+ )
224
+ case _: # pragma: no cover — Decision is a closed union
225
+ assert_never(decision)
226
+ finally:
227
+ if locked_id is not None and self._lock is not None:
228
+ self._lock.release(locked_id)
229
+ await self._gateway.close()
230
+
231
+ async def _apply_controls(self, controls: Sequence[ControlCommand]) -> None:
232
+ for command in controls:
233
+ match command:
234
+ case Stop():
235
+ continue
236
+ case Prompt(text=text):
237
+ self._queued_prompt = text
238
+ case SetModel(model=model):
239
+ self._model = model
240
+ profile = ModelEffortProfile(model=model, effort=self._effort)
241
+ await self._gateway.set_profile(profile)
242
+ case SetEffort(effort=effort):
243
+ self._effort = effort
244
+ await self._gateway.set_profile(
245
+ ModelEffortProfile(model=self._model, effort=effort)
246
+ )
247
+ case SetApproval(policy=policy):
248
+ self._approval = policy
249
+ await self._sync_permissions()
250
+ case SetSandbox(sandbox=sandbox):
251
+ self._sandbox = sandbox
252
+ await self._sync_permissions()
253
+ case SetCwd(cwd=cwd):
254
+ self._cwd = cwd
255
+ await self._gateway.set_cwd(cwd)
256
+ case Snapshot():
257
+ self._write_artifact(
258
+ "snapshot.json",
259
+ (
260
+ f'{{"run_id":"{self._run_id}","model":"{self._model}",'
261
+ f'"effort":"{self._effort.value}","cwd":"{self._cwd}"}}\n'
262
+ ),
263
+ )
264
+ case ResourceMutate(payload=payload):
265
+ await self._gateway.set_session_resources(payload)
266
+ case _: # pragma: no cover — ControlCommand is a closed union
267
+ assert_never(command)
268
+
269
+ async def _sync_permissions(self) -> None:
270
+ await self._gateway.set_permission_mode(_permission_mode(self._sandbox))
271
+ await self._gateway.set_session_resources(
272
+ {
273
+ "approval_policy": self._approval.value,
274
+ "sandbox_mode": self._sandbox.value,
275
+ }
276
+ )
277
+
278
+ def _take_prompt(self, default: str) -> str:
279
+ if self._queued_prompt is None:
280
+ return default
281
+ prompt = self._queued_prompt
282
+ self._queued_prompt = None
283
+ return prompt
284
+
285
+ def _thread_id_from(self, selector: SessionSelector) -> str | None:
286
+ match selector:
287
+ case Explicit(thread_id=thread_id):
288
+ return thread_id
289
+ case MostRecent():
290
+ return self._most_recent()
291
+ case PlanFile():
292
+ return None
293
+ case _: # pragma: no cover — SessionSelector is a closed union
294
+ assert_never(selector)
295
+
296
+ def _most_recent(self) -> str | None:
297
+ if self._catalog is None:
298
+ return None
299
+ threads = list(self._catalog.list_threads())
300
+ if not threads:
301
+ return None
302
+ latest = max(threads, key=lambda ref: ref.started_at)
303
+ return latest.thread_id
304
+
305
+ def _restore(self, thread_id: str | None, plan: str) -> tuple[str, list[str], bool]:
306
+ if thread_id is None:
307
+ return plan, [], True
308
+ stored = self._store.load(thread_id)
309
+ if stored is None:
310
+ return plan, [], True
311
+ plan_text = str(stored.get("plan_text") or plan)
312
+ remaining_raw = stored.get("remaining_work") or []
313
+ remaining = [str(item) for item in remaining_raw] if isinstance(remaining_raw, list) else []
314
+ first_turn = not bool(stored.get("first_turn_done"))
315
+ turns = _int_field(stored.get("turns"))
316
+ dollars = _float_field(stored.get("dollars"))
317
+ elapsed_s = _float_field(stored.get("elapsed_seconds"))
318
+ if turns or dollars or elapsed_s:
319
+ self._ledger.record(turns=turns, dollars=dollars, elapsed=timedelta(seconds=elapsed_s))
320
+ return plan_text, remaining, first_turn
321
+
322
+ def _maybe_lock(self, thread_id: str, locked_id: str | None) -> str | None:
323
+ if self._lock is None or locked_id is not None:
324
+ return locked_id
325
+ if self._lock.acquire(thread_id):
326
+ return thread_id
327
+ return None
328
+
329
+ def _record_elapsed(self, last_mark: datetime, now: datetime) -> datetime:
330
+ delta = now - last_mark
331
+ if delta > _ZERO:
332
+ self._ledger.record(elapsed=delta)
333
+ return now
334
+
335
+ def _notify_quota_once(self, capacity: CapacityState) -> None:
336
+ if not isinstance(capacity, QuotaExhausted) or self._quota_notified:
337
+ return
338
+ self._quota_notified = True
339
+ if self._notifier is None:
340
+ return
341
+ self._notifier.notify("Quota exhausted", capacity.reason)
342
+
343
+ def _report_unknown_code(self, turn: TurnOutcome) -> None:
344
+ if self._reporter is None or turn.signals is None:
345
+ return
346
+ code = turn.signals.error_code
347
+ if code is None:
348
+ return
349
+ if classify_code(code, None) is not ErrorClass.UNKNOWN:
350
+ return
351
+ self._reporter.report(
352
+ "capacity.unknown_code",
353
+ code=code,
354
+ http_status=turn.signals.http_status,
355
+ )
356
+
357
+ def _persist(
358
+ self,
359
+ *,
360
+ key: str | None,
361
+ remaining: Sequence[str],
362
+ first_turn_done: bool,
363
+ plan_text: str,
364
+ reason: str | None = None,
365
+ ) -> None:
366
+ run_id = key or self._run_id
367
+ state: dict[str, object] = {
368
+ "thread_id": key,
369
+ "turns": self._ledger.turns,
370
+ "dollars": self._ledger.dollars,
371
+ "elapsed_seconds": self._ledger.elapsed.total_seconds(),
372
+ "remaining_work": list(remaining),
373
+ "first_turn_done": first_turn_done,
374
+ "plan_text": plan_text,
375
+ }
376
+ if reason is not None:
377
+ state["reason"] = reason
378
+ self._store.save(run_id, state)
379
+
380
+ def _record_thread(self, thread_id: str | None, started: datetime) -> None:
381
+ if self._catalog is None or thread_id is None:
382
+ return
383
+ self._catalog.record(
384
+ ThreadRef(
385
+ thread_id=thread_id,
386
+ cwd=self._cwd,
387
+ started_at=started,
388
+ model=self._model,
389
+ )
390
+ )
391
+
392
+ def _write_stop_summary(self, thread_id: str | None, remaining: Sequence[str]) -> None:
393
+ items = "\n".join(f"- {item}" for item in remaining) if remaining else "_none_"
394
+ body = (
395
+ "# Stop summary\n\n"
396
+ "**Reason:** stop\n"
397
+ f"**Thread:** `{thread_id or 'unknown'}`\n"
398
+ f"**Turns:** {self._ledger.turns}\n\n"
399
+ "## Remaining work\n\n"
400
+ f"{items}\n"
401
+ )
402
+ self._write_artifact("stop-summary.md", body)
403
+
404
+
405
+ def _permission_mode(sandbox: SandboxMode) -> PermissionMode:
406
+ match sandbox:
407
+ case SandboxMode.READ_ONLY:
408
+ return PermissionMode.READ_ONLY
409
+ case SandboxMode.DANGER_FULL_ACCESS:
410
+ return PermissionMode.FULL_ACCESS
411
+ case SandboxMode.WORKSPACE_WRITE:
412
+ return PermissionMode.AUTONOMOUS
413
+ case _: # pragma: no cover — exhaustive StrEnum
414
+ assert_never(sandbox)
415
+
416
+
417
+ def _continuation(remaining: Sequence[str]) -> str:
418
+ if remaining:
419
+ items = "\n".join(f"- {name}" for name in remaining)
420
+ body = f"Continue. Remaining work:\n{items}"
421
+ else:
422
+ body = "Continue."
423
+ return (
424
+ f"{body}\n\n"
425
+ "When the entire task is fully complete, output "
426
+ f"{DEFAULT_DONE_MARKER} on its own line."
427
+ )
428
+
429
+
430
+ def _interpret(
431
+ turn: TurnOutcome, evaluator: CompletionEvaluator
432
+ ) -> tuple[CapacityState, CompletionVerdict]:
433
+ if turn.signals is None:
434
+ return Available(), Continue(remaining=[])
435
+ capacity = classify(turn.signals)
436
+ completion = evaluator.evaluate(turn.signals, capacity)
437
+ if isinstance(capacity, Available) and _is_fatal_turn(turn.signals):
438
+ reason = (
439
+ turn.signals.error_code
440
+ or turn.signals.error_type
441
+ or ("turn_failed" if turn.signals.failed else "fatal")
442
+ )
443
+ return capacity, Blocked(reason=reason)
444
+ return capacity, completion
445
+
446
+
447
+ def _is_fatal_turn(signals: TurnSignals) -> bool:
448
+ if classify_code(signals.error_code, None) is ErrorClass.FATAL:
449
+ return True
450
+ if classify_code(None, signals.error_type) is ErrorClass.FATAL:
451
+ return True
452
+ return signals.failed
453
+
454
+
455
+ def _int_field(value: object) -> int:
456
+ if isinstance(value, int):
457
+ return value
458
+ return 0
459
+
460
+
461
+ def _float_field(value: object) -> float:
462
+ if isinstance(value, int | float):
463
+ return float(value)
464
+ return 0.0
465
+
466
+
467
+ __all__ = ["AutonomousRunner", "RunnerContext"]
@@ -0,0 +1 @@
1
+ """Application use cases invoked by the CLI via bootstrap-wired adapters."""