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,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"