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.
- codexloop/__init__.py +3 -0
- codexloop/application/__init__.py +1 -0
- codexloop/application/dto.py +41 -0
- codexloop/application/ports.py +158 -0
- codexloop/application/runner.py +467 -0
- codexloop/application/usecases/__init__.py +1 -0
- codexloop/application/usecases/doctor.py +37 -0
- codexloop/application/usecases/list_threads.py +14 -0
- codexloop/application/usecases/preflight.py +10 -0
- codexloop/application/usecases/resume_thread.py +11 -0
- codexloop/application/usecases/run_control.py +20 -0
- codexloop/application/usecases/run_plan.py +11 -0
- codexloop/bootstrap.py +461 -0
- codexloop/cli/__init__.py +1 -0
- codexloop/cli/app.py +94 -0
- codexloop/cli/asyncio.py +80 -0
- codexloop/cli/commands/__init__.py +1 -0
- codexloop/cli/commands/approval_cmd.py +24 -0
- codexloop/cli/commands/capacity.py +33 -0
- codexloop/cli/commands/cwd_cmd.py +22 -0
- codexloop/cli/commands/doctor.py +27 -0
- codexloop/cli/commands/effort_cmd.py +24 -0
- codexloop/cli/commands/logs.py +15 -0
- codexloop/cli/commands/model_cmd.py +22 -0
- codexloop/cli/commands/prompt.py +32 -0
- codexloop/cli/commands/reset.py +25 -0
- codexloop/cli/commands/resume.py +38 -0
- codexloop/cli/commands/run.py +50 -0
- codexloop/cli/commands/runs.py +13 -0
- codexloop/cli/commands/sandbox_cmd.py +24 -0
- codexloop/cli/commands/savepoints.py +25 -0
- codexloop/cli/commands/snapshot.py +26 -0
- codexloop/cli/commands/status.py +15 -0
- codexloop/cli/commands/stop.py +21 -0
- codexloop/cli/commands/threads.py +15 -0
- codexloop/cli/commands/unwind.py +24 -0
- codexloop/cli/commands/watch.py +66 -0
- codexloop/cli/render.py +39 -0
- codexloop/domain/__init__.py +26 -0
- codexloop/domain/approval.py +38 -0
- codexloop/domain/backoff.py +34 -0
- codexloop/domain/budget.py +56 -0
- codexloop/domain/capacity.py +81 -0
- codexloop/domain/classify.py +98 -0
- codexloop/domain/completion.py +130 -0
- codexloop/domain/control.py +167 -0
- codexloop/domain/error_codes.py +75 -0
- codexloop/domain/errors.py +35 -0
- codexloop/domain/loop.py +190 -0
- codexloop/domain/model_profile.py +30 -0
- codexloop/domain/plan.py +38 -0
- codexloop/domain/savepoint.py +32 -0
- codexloop/domain/savepoint_message.py +56 -0
- codexloop/domain/session.py +32 -0
- codexloop/domain/signals.py +25 -0
- codexloop/domain/waiting.py +120 -0
- codexloop/infrastructure/__init__.py +0 -0
- codexloop/infrastructure/agent/__init__.py +0 -0
- codexloop/infrastructure/agent/argv.py +102 -0
- codexloop/infrastructure/agent/events.py +274 -0
- codexloop/infrastructure/agent/gateway.py +189 -0
- codexloop/infrastructure/agent/probe.py +74 -0
- codexloop/infrastructure/agent/process.py +208 -0
- codexloop/infrastructure/agent/schema.py +31 -0
- codexloop/infrastructure/agent/scripted.py +201 -0
- codexloop/infrastructure/agent/translate.py +120 -0
- codexloop/infrastructure/api/__init__.py +26 -0
- codexloop/infrastructure/api/api_baseline.json +340 -0
- codexloop/infrastructure/api/binder.py +170 -0
- codexloop/infrastructure/api/gateway.py +142 -0
- codexloop/infrastructure/api/introspect.py +248 -0
- codexloop/infrastructure/api/json_io.py +26 -0
- codexloop/infrastructure/api/params.py +162 -0
- codexloop/infrastructure/api/providers.py +70 -0
- codexloop/infrastructure/api/registry.py +13 -0
- codexloop/infrastructure/appserver/__init__.py +6 -0
- codexloop/infrastructure/appserver/client.py +245 -0
- codexloop/infrastructure/appserver/gateway.py +437 -0
- codexloop/infrastructure/appserver/ratelimits.py +100 -0
- codexloop/infrastructure/audit.py +26 -0
- codexloop/infrastructure/capacity_probe.py +57 -0
- codexloop/infrastructure/clock.py +26 -0
- codexloop/infrastructure/config.py +150 -0
- codexloop/infrastructure/control.py +89 -0
- codexloop/infrastructure/doctor_env.py +239 -0
- codexloop/infrastructure/events.py +23 -0
- codexloop/infrastructure/git_savepoints.py +176 -0
- codexloop/infrastructure/lock.py +88 -0
- codexloop/infrastructure/logging.py +124 -0
- codexloop/infrastructure/notify.py +27 -0
- codexloop/infrastructure/progress.py +14 -0
- codexloop/infrastructure/redact.py +52 -0
- codexloop/infrastructure/rollout.py +113 -0
- codexloop/infrastructure/rundir.py +57 -0
- codexloop/infrastructure/snapshot.py +39 -0
- codexloop/infrastructure/state.py +32 -0
- codexloop/infrastructure/state_bus.py +27 -0
- codexloop/infrastructure/stream_ui.py +44 -0
- codexloop/py.typed +0 -0
- codexloop-0.1.0.dist-info/METADATA +104 -0
- codexloop-0.1.0.dist-info/RECORD +104 -0
- codexloop-0.1.0.dist-info/WHEEL +4 -0
- codexloop-0.1.0.dist-info/entry_points.txt +2 -0
- codexloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Stdio JSON-RPC client for ``codex app-server`` (R6).
|
|
2
|
+
|
|
3
|
+
Newline-delimited JSON with **no** ``"jsonrpc"`` key. Handshake is
|
|
4
|
+
``initialize`` (with ``capabilities.experimentalApi: true``) → ``initialized``
|
|
5
|
+
→ call. ``account/rateLimitResetCredit/consume`` is never sent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Final
|
|
17
|
+
|
|
18
|
+
import anyio
|
|
19
|
+
from anyio import EndOfStream
|
|
20
|
+
from anyio.abc import ByteReceiveStream, ByteSendStream, Process
|
|
21
|
+
|
|
22
|
+
from codexloop.application.ports import Logger
|
|
23
|
+
from codexloop.domain.capacity import PlanWindows
|
|
24
|
+
from codexloop.infrastructure.appserver.ratelimits import plan_windows_from_rpc
|
|
25
|
+
|
|
26
|
+
DEFAULT_ARGV: Final[list[str]] = ["codex", "app-server", "--stdio"]
|
|
27
|
+
DEFAULT_TIMEOUT: Final[float] = 5.0
|
|
28
|
+
_KILL_GRACE_SECONDS: Final[float] = 0.5
|
|
29
|
+
_RECEIVE_CHUNK: Final[int] = 8192
|
|
30
|
+
_MAX_LINE_BYTES: Final[int] = 1_048_576
|
|
31
|
+
_READ_METHOD: Final[str] = "account/rateLimits/read"
|
|
32
|
+
_CONSUME_METHOD: Final[str] = "account/rateLimitResetCredit/consume"
|
|
33
|
+
|
|
34
|
+
_INITIALIZE: Final[dict[str, object]] = {
|
|
35
|
+
"id": 1,
|
|
36
|
+
"method": "initialize",
|
|
37
|
+
"params": {
|
|
38
|
+
"clientInfo": {"name": "codexloop", "title": "codexloop", "version": "0.1.0"},
|
|
39
|
+
"capabilities": {"experimentalApi": True},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
_INITIALIZED: Final[dict[str, object]] = {"method": "initialized", "params": {}}
|
|
43
|
+
_READ_RATE_LIMITS: Final[dict[str, object]] = {"id": 2, "method": _READ_METHOD}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AppServerClient:
|
|
47
|
+
"""One-shot stdio session: handshake, ``read_rate_limits``, then teardown."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
argv: Sequence[str] | None = None,
|
|
53
|
+
cwd: str | Path | None = None,
|
|
54
|
+
env: Mapping[str, str] | None = None,
|
|
55
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
56
|
+
logger: Logger | None = None,
|
|
57
|
+
now: datetime | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._argv = list(DEFAULT_ARGV if argv is None else argv)
|
|
60
|
+
self._cwd = Path.cwd() if cwd is None else Path(cwd)
|
|
61
|
+
self._env = dict(env) if env is not None else None
|
|
62
|
+
self._timeout = timeout
|
|
63
|
+
self._logger = logger
|
|
64
|
+
self._now = now
|
|
65
|
+
|
|
66
|
+
async def read_rate_limits(self) -> PlanWindows | None:
|
|
67
|
+
"""Return plan windows, or ``None`` on any failure. Never raises."""
|
|
68
|
+
try:
|
|
69
|
+
return await self._read_rate_limits()
|
|
70
|
+
except Exception: # pragma: no cover — best-effort probe
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
async def _read_rate_limits(self) -> PlanWindows | None:
|
|
74
|
+
env = self._env if self._env is not None else os.environ.copy()
|
|
75
|
+
try:
|
|
76
|
+
process = await anyio.open_process(
|
|
77
|
+
self._argv,
|
|
78
|
+
cwd=os.fspath(self._cwd),
|
|
79
|
+
env=env,
|
|
80
|
+
start_new_session=True,
|
|
81
|
+
)
|
|
82
|
+
except OSError: # pragma: no cover — spawn failure
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
return await self._exchange(process)
|
|
86
|
+
finally:
|
|
87
|
+
await _terminate_group(process)
|
|
88
|
+
with anyio.CancelScope(shield=True):
|
|
89
|
+
await process.aclose()
|
|
90
|
+
|
|
91
|
+
async def _exchange(self, process: Process) -> PlanWindows | None:
|
|
92
|
+
stdin = process.stdin
|
|
93
|
+
stdout = process.stdout
|
|
94
|
+
if stdin is None or stdout is None: # pragma: no cover
|
|
95
|
+
return None
|
|
96
|
+
reader = _LineReader(stdout)
|
|
97
|
+
windows: PlanWindows | None = None
|
|
98
|
+
async with anyio.create_task_group() as tg:
|
|
99
|
+
if process.stderr is not None: # pragma: no branch
|
|
100
|
+
tg.start_soon(_drain_stderr, process.stderr)
|
|
101
|
+
try:
|
|
102
|
+
windows = await self._session(stdin, reader)
|
|
103
|
+
finally:
|
|
104
|
+
tg.cancel_scope.cancel()
|
|
105
|
+
return windows
|
|
106
|
+
|
|
107
|
+
async def _session(self, stdin: ByteSendStream, reader: _LineReader) -> PlanWindows | None:
|
|
108
|
+
init = await self._request(stdin, reader, dict(_INITIALIZE), request_id=1)
|
|
109
|
+
if init is None or _rpc_error(init) is not None:
|
|
110
|
+
return None
|
|
111
|
+
await _send(stdin, dict(_INITIALIZED))
|
|
112
|
+
response = await self._request(stdin, reader, dict(_READ_RATE_LIMITS), request_id=2)
|
|
113
|
+
if response is None:
|
|
114
|
+
return None
|
|
115
|
+
error = _rpc_error(response)
|
|
116
|
+
if error is not None:
|
|
117
|
+
self._warn_missing_capability(error)
|
|
118
|
+
return None
|
|
119
|
+
result = response.get("result")
|
|
120
|
+
now = self._now if self._now is not None else datetime.now(UTC)
|
|
121
|
+
return plan_windows_from_rpc(result, now=now)
|
|
122
|
+
|
|
123
|
+
async def _request(
|
|
124
|
+
self,
|
|
125
|
+
stdin: ByteSendStream,
|
|
126
|
+
reader: _LineReader,
|
|
127
|
+
message: Mapping[str, object],
|
|
128
|
+
*,
|
|
129
|
+
request_id: int,
|
|
130
|
+
) -> dict[str, Any] | None:
|
|
131
|
+
if not await _send(stdin, message): # pragma: no cover — consume guard
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
with anyio.fail_after(self._timeout):
|
|
135
|
+
return await reader.read_matching(request_id)
|
|
136
|
+
except TimeoutError: # pragma: no cover
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def _warn_missing_capability(self, error: Mapping[str, object]) -> None:
|
|
140
|
+
raw = error.get("message")
|
|
141
|
+
text = raw if isinstance(raw, str) else ""
|
|
142
|
+
if "experimentalapi" not in text.lower():
|
|
143
|
+
return
|
|
144
|
+
if self._logger is None: # pragma: no cover
|
|
145
|
+
return
|
|
146
|
+
self._logger.warning("appserver_missing_capability", error=text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _send(stdin: ByteSendStream, message: Mapping[str, object]) -> bool:
|
|
150
|
+
if message.get("method") == _CONSUME_METHOD: # pragma: no cover — never-consume
|
|
151
|
+
return False
|
|
152
|
+
payload = dict(message)
|
|
153
|
+
payload.pop("jsonrpc", None)
|
|
154
|
+
line = json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
155
|
+
await stdin.send(line)
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _rpc_error(response: Mapping[str, Any]) -> Mapping[str, object] | None:
|
|
160
|
+
error = response.get("error")
|
|
161
|
+
if isinstance(error, Mapping):
|
|
162
|
+
return dict(error)
|
|
163
|
+
if error is not None:
|
|
164
|
+
return {"message": str(error)} # pragma: no cover — non-mapping error
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _drain_stderr(stream: ByteReceiveStream) -> None:
|
|
169
|
+
while True:
|
|
170
|
+
try:
|
|
171
|
+
await stream.receive(_RECEIVE_CHUNK)
|
|
172
|
+
except EndOfStream:
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _terminate_group(process: Process) -> None:
|
|
177
|
+
pid = process.pid
|
|
178
|
+
with anyio.CancelScope(shield=True):
|
|
179
|
+
_signal_group(pid, signal.SIGTERM)
|
|
180
|
+
with anyio.move_on_after(_KILL_GRACE_SECONDS):
|
|
181
|
+
await process.wait()
|
|
182
|
+
_signal_group(pid, signal.SIGKILL)
|
|
183
|
+
with anyio.move_on_after(2.0):
|
|
184
|
+
await process.wait()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _signal_group(pid: int, sig: int) -> None:
|
|
188
|
+
try:
|
|
189
|
+
os.killpg(pid, sig)
|
|
190
|
+
except ProcessLookupError: # pragma: no cover
|
|
191
|
+
return
|
|
192
|
+
except OSError: # pragma: no cover
|
|
193
|
+
try:
|
|
194
|
+
os.kill(pid, sig)
|
|
195
|
+
except (ProcessLookupError, OSError):
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _LineReader:
|
|
200
|
+
def __init__(self, stream: ByteReceiveStream, *, max_line_bytes: int = _MAX_LINE_BYTES) -> None:
|
|
201
|
+
self._stream = stream
|
|
202
|
+
self._max_line_bytes = max_line_bytes
|
|
203
|
+
self._buf = bytearray()
|
|
204
|
+
self._eof = False
|
|
205
|
+
|
|
206
|
+
async def read_matching(self, request_id: int) -> dict[str, Any] | None:
|
|
207
|
+
while True:
|
|
208
|
+
line = await self._readline()
|
|
209
|
+
if line is None:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
obj = json.loads(line)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
return None
|
|
215
|
+
if not isinstance(obj, dict): # pragma: no cover
|
|
216
|
+
return None
|
|
217
|
+
if obj.get("id") == request_id:
|
|
218
|
+
return obj
|
|
219
|
+
|
|
220
|
+
async def _readline(self) -> str | None:
|
|
221
|
+
while True:
|
|
222
|
+
nl = self._buf.find(b"\n")
|
|
223
|
+
if nl != -1:
|
|
224
|
+
raw = bytes(self._buf[:nl])
|
|
225
|
+
del self._buf[: nl + 1]
|
|
226
|
+
if len(raw) > self._max_line_bytes: # pragma: no cover
|
|
227
|
+
return None
|
|
228
|
+
return raw.decode("utf-8", errors="replace")
|
|
229
|
+
if self._eof:
|
|
230
|
+
if not self._buf: # pragma: no branch — trailing buffer rare
|
|
231
|
+
return None
|
|
232
|
+
raw = bytes(self._buf) # pragma: no cover
|
|
233
|
+
self._buf.clear() # pragma: no cover
|
|
234
|
+
if len(raw) > self._max_line_bytes: # pragma: no cover
|
|
235
|
+
return None
|
|
236
|
+
return raw.decode("utf-8", errors="replace") # pragma: no cover
|
|
237
|
+
try:
|
|
238
|
+
chunk = await self._stream.receive(_RECEIVE_CHUNK)
|
|
239
|
+
except EndOfStream:
|
|
240
|
+
self._eof = True
|
|
241
|
+
continue
|
|
242
|
+
if not chunk: # pragma: no cover
|
|
243
|
+
self._eof = True
|
|
244
|
+
continue
|
|
245
|
+
self._buf.extend(chunk)
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Long-lived ``codex app-server`` session implementing :class:`AgentGateway`.
|
|
2
|
+
|
|
3
|
+
Optional second transport (R10 / ADR 0009). Speaks newline-delimited JSON-RPC
|
|
4
|
+
without a ``"jsonrpc"`` key. Auto-answers approval requests. Mid-turn stop and
|
|
5
|
+
steer use ``turn/interrupt`` / ``turn/steer``.
|
|
6
|
+
|
|
7
|
+
The fake shim under ``tests/shim/fake_appserver.py`` defines the contract this
|
|
8
|
+
adapter is tested against. Live ``codex app-server`` remains experimental; when
|
|
9
|
+
capability probing fails, bootstrap falls back to exec.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Final, assert_never
|
|
21
|
+
|
|
22
|
+
import anyio
|
|
23
|
+
from anyio import EndOfStream
|
|
24
|
+
from anyio.abc import ByteReceiveStream, ByteSendStream, Process
|
|
25
|
+
|
|
26
|
+
from codexloop.application.dto import TokenUsage, TurnOutcome
|
|
27
|
+
from codexloop.application.ports import Logger, PermissionMode
|
|
28
|
+
from codexloop.domain.approval import ApprovalPolicy, SandboxMode
|
|
29
|
+
from codexloop.domain.model_profile import ModelEffortProfile
|
|
30
|
+
from codexloop.domain.signals import TurnSignals
|
|
31
|
+
from codexloop.infrastructure.appserver.client import DEFAULT_ARGV
|
|
32
|
+
|
|
33
|
+
_KILL_GRACE_SECONDS: Final[float] = 0.5
|
|
34
|
+
_RECEIVE_CHUNK: Final[int] = 8192
|
|
35
|
+
_MAX_LINE_BYTES: Final[int] = 1_048_576
|
|
36
|
+
_DEFAULT_TIMEOUT: Final[float] = 30.0
|
|
37
|
+
_CONSUME_METHOD: Final[str] = "account/rateLimitResetCredit/consume"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class _PendingTurn:
|
|
42
|
+
turn_id: str | None = None
|
|
43
|
+
final_message: str | None = None
|
|
44
|
+
completed: bool = False
|
|
45
|
+
failed: bool = False
|
|
46
|
+
error_code: str | None = None
|
|
47
|
+
error_type: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CodexAppServerGateway:
|
|
51
|
+
"""Bidirectional app-server adapter behind :class:`AgentGateway`."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
cwd: str | Path,
|
|
57
|
+
argv: Sequence[str] | None = None,
|
|
58
|
+
env: Mapping[str, str] | None = None,
|
|
59
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
60
|
+
logger: Logger | None = None,
|
|
61
|
+
model: str | None = None,
|
|
62
|
+
approval: ApprovalPolicy = ApprovalPolicy.NEVER,
|
|
63
|
+
sandbox: SandboxMode = SandboxMode.WORKSPACE_WRITE,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._cwd = Path(cwd)
|
|
66
|
+
self._argv = list(DEFAULT_ARGV if argv is None else argv)
|
|
67
|
+
self._env = dict(env) if env is not None else None
|
|
68
|
+
self._timeout = timeout
|
|
69
|
+
self._logger = logger
|
|
70
|
+
self._model = model
|
|
71
|
+
self._approval = approval
|
|
72
|
+
self._sandbox = sandbox
|
|
73
|
+
self._thread_id: str | None = None
|
|
74
|
+
self._closed = False
|
|
75
|
+
self._process: Process | None = None
|
|
76
|
+
self._stdin: ByteSendStream | None = None
|
|
77
|
+
self._reader: _LineReader | None = None
|
|
78
|
+
self._next_id = 1
|
|
79
|
+
self._pending: _PendingTurn | None = None
|
|
80
|
+
self._lock = anyio.Lock()
|
|
81
|
+
|
|
82
|
+
async def probe_capabilities(self) -> bool:
|
|
83
|
+
"""Return True when initialize + initialized succeed."""
|
|
84
|
+
try:
|
|
85
|
+
await self._ensure_session()
|
|
86
|
+
return True
|
|
87
|
+
except Exception:
|
|
88
|
+
await self.close()
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
async def send_turn(self, prompt: str) -> TurnOutcome:
|
|
92
|
+
async with self._lock:
|
|
93
|
+
await self._ensure_session()
|
|
94
|
+
if self._stdin is None or self._reader is None: # pragma: no cover
|
|
95
|
+
msg = "app-server session missing streams"
|
|
96
|
+
raise RuntimeError(msg)
|
|
97
|
+
if self._thread_id is None:
|
|
98
|
+
self._pending = _PendingTurn()
|
|
99
|
+
started = await self._request(
|
|
100
|
+
"thread/start",
|
|
101
|
+
{
|
|
102
|
+
"input": [{"type": "text", "text": prompt}],
|
|
103
|
+
"model": self._model,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
self._thread_id = _dig_str(started, "thread", "id") or _dig_str(started, "id")
|
|
107
|
+
if self._thread_id is None: # pragma: no cover — shim always returns an id
|
|
108
|
+
self._pending = None
|
|
109
|
+
return _failed_outcome("thread_start_missing_id")
|
|
110
|
+
else:
|
|
111
|
+
self._pending = _PendingTurn()
|
|
112
|
+
await self._request(
|
|
113
|
+
"turn/start",
|
|
114
|
+
{
|
|
115
|
+
"threadId": self._thread_id,
|
|
116
|
+
"input": [{"type": "text", "text": prompt}],
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
await self._drain_until_turn_done()
|
|
120
|
+
pending = self._pending
|
|
121
|
+
self._pending = None
|
|
122
|
+
if pending is None: # pragma: no cover — pending set before drain
|
|
123
|
+
return _failed_outcome("turn_missing_state")
|
|
124
|
+
signals = TurnSignals(
|
|
125
|
+
error_code=pending.error_code,
|
|
126
|
+
error_type=pending.error_type,
|
|
127
|
+
completed=pending.completed and not pending.failed,
|
|
128
|
+
failed=pending.failed,
|
|
129
|
+
final_message=pending.final_message,
|
|
130
|
+
exit_code=1 if pending.failed else 0,
|
|
131
|
+
)
|
|
132
|
+
return TurnOutcome(
|
|
133
|
+
signals=signals,
|
|
134
|
+
usage=TokenUsage(),
|
|
135
|
+
exit_code=signals.exit_code,
|
|
136
|
+
thread_id=self._thread_id,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def interrupt_turn(self) -> None:
|
|
140
|
+
"""True mid-turn stop via ``turn/interrupt``."""
|
|
141
|
+
async with self._lock:
|
|
142
|
+
if self._pending is None or self._pending.turn_id is None:
|
|
143
|
+
return
|
|
144
|
+
await self._request("turn/interrupt", {"turnId": self._pending.turn_id})
|
|
145
|
+
|
|
146
|
+
async def steer_turn(self, text: str) -> None:
|
|
147
|
+
"""Inject an operator prompt into the running turn via ``turn/steer``."""
|
|
148
|
+
async with self._lock:
|
|
149
|
+
if self._pending is None or self._pending.turn_id is None:
|
|
150
|
+
return
|
|
151
|
+
await self._request(
|
|
152
|
+
"turn/steer",
|
|
153
|
+
{
|
|
154
|
+
"turnId": self._pending.turn_id,
|
|
155
|
+
"input": [{"type": "text", "text": text}],
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def close(self) -> None:
|
|
160
|
+
if self._closed and self._process is None:
|
|
161
|
+
return
|
|
162
|
+
self._closed = True
|
|
163
|
+
process = self._process
|
|
164
|
+
self._process = None
|
|
165
|
+
self._stdin = None
|
|
166
|
+
self._reader = None
|
|
167
|
+
if process is not None:
|
|
168
|
+
await _terminate_group(process)
|
|
169
|
+
with anyio.CancelScope(shield=True):
|
|
170
|
+
await process.aclose()
|
|
171
|
+
|
|
172
|
+
async def set_profile(self, profile: ModelEffortProfile) -> None:
|
|
173
|
+
self._model = profile.model
|
|
174
|
+
|
|
175
|
+
async def set_permission_mode(self, mode: PermissionMode) -> None:
|
|
176
|
+
match mode:
|
|
177
|
+
case PermissionMode.AUTONOMOUS:
|
|
178
|
+
self._approval, self._sandbox = (
|
|
179
|
+
ApprovalPolicy.NEVER,
|
|
180
|
+
SandboxMode.WORKSPACE_WRITE,
|
|
181
|
+
)
|
|
182
|
+
case PermissionMode.READ_ONLY:
|
|
183
|
+
self._approval, self._sandbox = ApprovalPolicy.NEVER, SandboxMode.READ_ONLY
|
|
184
|
+
case PermissionMode.FULL_ACCESS:
|
|
185
|
+
self._approval, self._sandbox = (
|
|
186
|
+
ApprovalPolicy.NEVER,
|
|
187
|
+
SandboxMode.DANGER_FULL_ACCESS,
|
|
188
|
+
)
|
|
189
|
+
case _: # pragma: no cover — exhaustive StrEnum
|
|
190
|
+
assert_never(mode)
|
|
191
|
+
|
|
192
|
+
async def set_cwd(self, path: str) -> None:
|
|
193
|
+
self._cwd = Path(path)
|
|
194
|
+
|
|
195
|
+
async def set_session_resources(self, resources: Mapping[str, object]) -> None:
|
|
196
|
+
approval = resources.get("approval_policy")
|
|
197
|
+
if isinstance(approval, str):
|
|
198
|
+
self._approval = ApprovalPolicy(approval)
|
|
199
|
+
sandbox = resources.get("sandbox_mode")
|
|
200
|
+
if isinstance(sandbox, str):
|
|
201
|
+
self._sandbox = SandboxMode(sandbox)
|
|
202
|
+
|
|
203
|
+
def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool:
|
|
204
|
+
del request_id, reason
|
|
205
|
+
return allow
|
|
206
|
+
|
|
207
|
+
async def _ensure_session(self) -> None:
|
|
208
|
+
if self._process is not None and self._stdin is not None and self._reader is not None:
|
|
209
|
+
self._closed = False
|
|
210
|
+
return
|
|
211
|
+
env = self._env if self._env is not None else os.environ.copy()
|
|
212
|
+
process = await anyio.open_process(
|
|
213
|
+
self._argv,
|
|
214
|
+
cwd=os.fspath(self._cwd),
|
|
215
|
+
env=env,
|
|
216
|
+
start_new_session=True,
|
|
217
|
+
)
|
|
218
|
+
if process.stdin is None or process.stdout is None: # pragma: no cover
|
|
219
|
+
await process.aclose()
|
|
220
|
+
msg = "app-server process missing stdio"
|
|
221
|
+
raise RuntimeError(msg)
|
|
222
|
+
self._process = process
|
|
223
|
+
self._stdin = process.stdin
|
|
224
|
+
self._reader = _LineReader(process.stdout)
|
|
225
|
+
self._closed = False
|
|
226
|
+
init = await self._request(
|
|
227
|
+
"initialize",
|
|
228
|
+
{
|
|
229
|
+
"clientInfo": {
|
|
230
|
+
"name": "codexloop",
|
|
231
|
+
"title": "codexloop",
|
|
232
|
+
"version": "0.1.0",
|
|
233
|
+
},
|
|
234
|
+
"capabilities": {"experimentalApi": True},
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
if init is None: # pragma: no cover — probed via shim init_fail
|
|
238
|
+
msg = "app-server initialize failed"
|
|
239
|
+
raise RuntimeError(msg)
|
|
240
|
+
await _send(self._stdin, {"method": "initialized", "params": {}})
|
|
241
|
+
|
|
242
|
+
async def _request(
|
|
243
|
+
self,
|
|
244
|
+
method: str,
|
|
245
|
+
params: Mapping[str, object] | None = None,
|
|
246
|
+
) -> dict[str, Any] | None:
|
|
247
|
+
if self._stdin is None or self._reader is None: # pragma: no cover
|
|
248
|
+
return None
|
|
249
|
+
req_id = self._next_id
|
|
250
|
+
self._next_id += 1
|
|
251
|
+
message: dict[str, object] = {"id": req_id, "method": method}
|
|
252
|
+
if params is not None:
|
|
253
|
+
message["params"] = dict(params)
|
|
254
|
+
if not await _send(self._stdin, message): # pragma: no cover — consume guard
|
|
255
|
+
return None
|
|
256
|
+
try:
|
|
257
|
+
with anyio.fail_after(self._timeout):
|
|
258
|
+
while True:
|
|
259
|
+
msg = await self._reader.read_line()
|
|
260
|
+
if msg is None: # pragma: no cover — EOF mid-request
|
|
261
|
+
return None
|
|
262
|
+
if await self._handle_notification(msg):
|
|
263
|
+
continue # pragma: no cover — notifications usually arrive in drain
|
|
264
|
+
if msg.get("id") == req_id:
|
|
265
|
+
if _rpc_error(msg) is not None: # pragma: no cover
|
|
266
|
+
return None
|
|
267
|
+
result = msg.get("result")
|
|
268
|
+
return dict(result) if isinstance(result, Mapping) else {"result": result}
|
|
269
|
+
except TimeoutError: # pragma: no cover — timed waits covered elsewhere
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
async def _drain_until_turn_done(self) -> None:
|
|
273
|
+
if self._reader is None or self._pending is None: # pragma: no cover
|
|
274
|
+
return
|
|
275
|
+
try:
|
|
276
|
+
with anyio.fail_after(self._timeout):
|
|
277
|
+
while not self._pending.completed and not self._pending.failed:
|
|
278
|
+
msg = await self._reader.read_line()
|
|
279
|
+
if msg is None: # pragma: no cover
|
|
280
|
+
self._pending.failed = True
|
|
281
|
+
self._pending.error_code = "stream_ended"
|
|
282
|
+
return
|
|
283
|
+
await self._handle_notification(msg)
|
|
284
|
+
except TimeoutError: # pragma: no cover
|
|
285
|
+
self._pending.failed = True
|
|
286
|
+
self._pending.error_code = "turn_timeout"
|
|
287
|
+
|
|
288
|
+
async def _handle_notification(self, msg: Mapping[str, Any]) -> bool:
|
|
289
|
+
method = msg.get("method")
|
|
290
|
+
if not isinstance(method, str):
|
|
291
|
+
return False
|
|
292
|
+
params = msg.get("params")
|
|
293
|
+
params_map = dict(params) if isinstance(params, Mapping) else {}
|
|
294
|
+
if method in {"turn/started", "turn.started"}:
|
|
295
|
+
if self._pending is not None: # pragma: no branch
|
|
296
|
+
self._pending.turn_id = _dig_str(params_map, "turn", "id") or _dig_str(
|
|
297
|
+
params_map, "turnId"
|
|
298
|
+
)
|
|
299
|
+
return True
|
|
300
|
+
if method in {"item/agentMessage/delta", "turn/outputDelta"}:
|
|
301
|
+
text = params_map.get("delta") or params_map.get("text")
|
|
302
|
+
if self._pending is not None and isinstance(text, str): # pragma: no branch
|
|
303
|
+
prev = self._pending.final_message or ""
|
|
304
|
+
self._pending.final_message = prev + text
|
|
305
|
+
return True
|
|
306
|
+
if method in {"turn/completed", "turn.completed"}:
|
|
307
|
+
if self._pending is not None: # pragma: no branch
|
|
308
|
+
self._pending.completed = True
|
|
309
|
+
final = params_map.get("finalMessage") or params_map.get("message")
|
|
310
|
+
if isinstance(final, str): # pragma: no branch
|
|
311
|
+
self._pending.final_message = final
|
|
312
|
+
return True
|
|
313
|
+
if method in {"turn/failed", "turn.failed"}:
|
|
314
|
+
if self._pending is not None: # pragma: no branch
|
|
315
|
+
self._pending.failed = True
|
|
316
|
+
self._pending.error_code = str(params_map.get("code") or "turn_failed")
|
|
317
|
+
err_type = params_map.get("type")
|
|
318
|
+
self._pending.error_type = str(err_type) if err_type is not None else None
|
|
319
|
+
return True
|
|
320
|
+
if method in {"approval/request", "tool/approval/request"}:
|
|
321
|
+
req_id = params_map.get("requestId") or params_map.get("id")
|
|
322
|
+
await self._answer_approval(str(req_id) if req_id is not None else "0", allow=True)
|
|
323
|
+
return True
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
async def _answer_approval(self, request_id: str, *, allow: bool) -> None:
|
|
327
|
+
if self._stdin is None: # pragma: no cover
|
|
328
|
+
return
|
|
329
|
+
await _send(
|
|
330
|
+
self._stdin,
|
|
331
|
+
{
|
|
332
|
+
"method": "approval/respond",
|
|
333
|
+
"params": {"requestId": request_id, "allow": allow},
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _failed_outcome(code: str) -> TurnOutcome: # pragma: no cover — defensive helper
|
|
339
|
+
return TurnOutcome(
|
|
340
|
+
signals=TurnSignals(failed=True, error_code=code, exit_code=1),
|
|
341
|
+
exit_code=1,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _dig_str(obj: Mapping[str, Any] | None, *keys: str) -> str | None:
|
|
346
|
+
cur: Any = obj
|
|
347
|
+
for key in keys:
|
|
348
|
+
if not isinstance(cur, Mapping):
|
|
349
|
+
return None
|
|
350
|
+
cur = cur.get(key)
|
|
351
|
+
return cur if isinstance(cur, str) else None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def _send(stdin: ByteSendStream, message: Mapping[str, object]) -> bool:
|
|
355
|
+
if message.get("method") == _CONSUME_METHOD: # pragma: no cover — never-consume guard
|
|
356
|
+
return False
|
|
357
|
+
payload = dict(message)
|
|
358
|
+
payload.pop("jsonrpc", None)
|
|
359
|
+
line = json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
360
|
+
await stdin.send(line)
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _rpc_error(response: Mapping[str, Any]) -> Mapping[str, object] | None:
|
|
365
|
+
error = response.get("error")
|
|
366
|
+
if isinstance(error, Mapping):
|
|
367
|
+
return dict(error)
|
|
368
|
+
if error is not None: # pragma: no cover — non-mapping error payloads
|
|
369
|
+
return {"message": str(error)}
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def _terminate_group(process: Process) -> None: # pragma: no cover — process teardown races
|
|
374
|
+
pid = process.pid
|
|
375
|
+
with anyio.CancelScope(shield=True):
|
|
376
|
+
_signal_group(pid, signal.SIGTERM)
|
|
377
|
+
with anyio.move_on_after(_KILL_GRACE_SECONDS):
|
|
378
|
+
await process.wait()
|
|
379
|
+
_signal_group(pid, signal.SIGKILL)
|
|
380
|
+
with anyio.move_on_after(2.0):
|
|
381
|
+
await process.wait()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _signal_group(pid: int, sig: int) -> None:
|
|
385
|
+
try:
|
|
386
|
+
os.killpg(pid, sig)
|
|
387
|
+
except ProcessLookupError: # pragma: no cover — race with process exit
|
|
388
|
+
return
|
|
389
|
+
except OSError: # pragma: no cover — fallback when killpg unsupported
|
|
390
|
+
try:
|
|
391
|
+
os.kill(pid, sig)
|
|
392
|
+
except (ProcessLookupError, OSError):
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class _LineReader:
|
|
397
|
+
def __init__(self, stream: ByteReceiveStream) -> None:
|
|
398
|
+
self._stream = stream
|
|
399
|
+
self._buf = bytearray()
|
|
400
|
+
|
|
401
|
+
async def read_line(self) -> dict[str, Any] | None:
|
|
402
|
+
while True:
|
|
403
|
+
newline = self._buf.find(b"\n")
|
|
404
|
+
if newline >= 0:
|
|
405
|
+
raw = bytes(self._buf[:newline])
|
|
406
|
+
del self._buf[: newline + 1]
|
|
407
|
+
if not raw.strip():
|
|
408
|
+
continue
|
|
409
|
+
try:
|
|
410
|
+
data = json.loads(raw.decode("utf-8"))
|
|
411
|
+
except (UnicodeDecodeError, json.JSONDecodeError): # pragma: no cover
|
|
412
|
+
continue
|
|
413
|
+
return data if isinstance(data, dict) else None
|
|
414
|
+
if len(self._buf) > _MAX_LINE_BYTES: # pragma: no cover — pathological line
|
|
415
|
+
self._buf.clear()
|
|
416
|
+
return None
|
|
417
|
+
try:
|
|
418
|
+
chunk = await self._stream.receive(_RECEIVE_CHUNK)
|
|
419
|
+
except EndOfStream:
|
|
420
|
+
return None
|
|
421
|
+
if not chunk: # pragma: no cover
|
|
422
|
+
return None
|
|
423
|
+
self._buf.extend(chunk)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def probe_app_server_transport(
|
|
427
|
+
*,
|
|
428
|
+
cwd: Path,
|
|
429
|
+
argv: Sequence[str] | None = None,
|
|
430
|
+
env: Mapping[str, str] | None = None,
|
|
431
|
+
) -> tuple[CodexAppServerGateway | None, str | None]:
|
|
432
|
+
"""Capability probe. Returns ``(gateway, None)`` or ``(None, reason)``."""
|
|
433
|
+
gateway = CodexAppServerGateway(cwd=cwd, argv=argv, env=env, timeout=5.0)
|
|
434
|
+
ok = await gateway.probe_capabilities()
|
|
435
|
+
if ok:
|
|
436
|
+
return gateway, None
|
|
437
|
+
return None, "app-server initialize failed; falling back to exec"
|