outfitter-dispatch 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.
- outfitter/dispatch/__init__.py +6 -0
- outfitter/dispatch/cli.py +16 -0
- outfitter/dispatch/client/AGENTS.md +29 -0
- outfitter/dispatch/client/__init__.py +47 -0
- outfitter/dispatch/client/client.py +360 -0
- outfitter/dispatch/client/errors.py +33 -0
- outfitter/dispatch/client/events.py +163 -0
- outfitter/dispatch/client/models.py +247 -0
- outfitter/dispatch/client/router.py +148 -0
- outfitter/dispatch/client/transport.py +123 -0
- outfitter/dispatch/config.py +34 -0
- outfitter/dispatch/contracts/AGENTS.md +54 -0
- outfitter/dispatch/contracts/__init__.py +43 -0
- outfitter/dispatch/contracts/context.py +144 -0
- outfitter/dispatch/contracts/derive_cli.py +388 -0
- outfitter/dispatch/contracts/derive_mcp.py +191 -0
- outfitter/dispatch/contracts/errors.py +101 -0
- outfitter/dispatch/contracts/examples.py +51 -0
- outfitter/dispatch/contracts/execute.py +27 -0
- outfitter/dispatch/contracts/op.py +75 -0
- outfitter/dispatch/contracts/registry.py +38 -0
- outfitter/dispatch/core/__init__.py +2 -0
- outfitter/dispatch/core/handlers.py +631 -0
- outfitter/dispatch/core/models.py +331 -0
- outfitter/dispatch/core/new_config.py +255 -0
- outfitter/dispatch/core/ops.py +403 -0
- outfitter/dispatch/core/queue.py +59 -0
- outfitter/dispatch/core/reactor.py +93 -0
- outfitter/dispatch/core/scheduler.py +79 -0
- outfitter/dispatch/core/trigger_handlers.py +118 -0
- outfitter/dispatch/core/triggers.py +102 -0
- outfitter/dispatch/daemon/__init__.py +6 -0
- outfitter/dispatch/daemon/__main__.py +61 -0
- outfitter/dispatch/daemon/control.py +94 -0
- outfitter/dispatch/daemon/host.py +79 -0
- outfitter/dispatch/daemon/lifecycle.py +103 -0
- outfitter/dispatch/daemon/supervisor.py +94 -0
- outfitter/dispatch/registry/__init__.py +2 -0
- outfitter/dispatch/registry/models.py +121 -0
- outfitter/dispatch/registry/store.py +380 -0
- outfitter/dispatch/surfaces/AGENTS.md +24 -0
- outfitter/dispatch/surfaces/__init__.py +5 -0
- outfitter/dispatch/surfaces/cli.py +122 -0
- outfitter/dispatch/surfaces/mcp.py +137 -0
- outfitter/dispatch/version.py +15 -0
- outfitter_dispatch-0.1.0.dist-info/METADATA +87 -0
- outfitter_dispatch-0.1.0.dist-info/RECORD +49 -0
- outfitter_dispatch-0.1.0.dist-info/WHEEL +4 -0
- outfitter_dispatch-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""dispatch CLI entrypoint (console script ``dispatch``).
|
|
2
|
+
|
|
3
|
+
The app is *derived* from the op registry — there are no hand-written per-op
|
|
4
|
+
commands here (see ``surfaces/cli.py`` and ``contracts/derive_cli.py``). Adding
|
|
5
|
+
an op makes it appear on the CLI automatically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from outfitter.dispatch.surfaces.cli import build_cli
|
|
11
|
+
|
|
12
|
+
app = build_cli()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
app()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# client/ — the App Server client
|
|
2
|
+
|
|
3
|
+
Path: `src/outfitter/dispatch/client/`. The ONLY place that spawns or speaks to `codex app-server`. Importable standalone (no daemon dependency).
|
|
4
|
+
|
|
5
|
+
## Transport
|
|
6
|
+
|
|
7
|
+
- Spawn `codex app-server --listen stdio://` via `asyncio.create_subprocess_exec`. **stdio JSONL is the only bare-JSON transport** — one newline-delimited JSON message per line. (unix/ws are WebSocket-framed; the managed daemon control socket is auth-gated. Do not use them.)
|
|
8
|
+
- One app-server process hosts many lanes. A single connection multiplexes them.
|
|
9
|
+
- Detect crash via stdout EOF; surface it so the daemon can restart + re-resume.
|
|
10
|
+
|
|
11
|
+
## Message router
|
|
12
|
+
|
|
13
|
+
Demux the single stream: responses by request `id`, notifications by `threadId`, into per-lane async queues + a global stream. Expose `events(thread_id | all)`. This is the verified pattern (mirrors the Python SDK's internal router).
|
|
14
|
+
|
|
15
|
+
## Primitives (typed; Pydantic wire models)
|
|
16
|
+
|
|
17
|
+
`initialize` → `thread_start/resume/list/read/archive` → `turn_start/steer/interrupt` → `inject_items` → approval responder. Verified gotchas to encode:
|
|
18
|
+
|
|
19
|
+
- `thread/start.sandbox` is a **string** enum (`read-only`/`workspace-write`/`danger-full-access`); `turn/start.sandboxPolicy` is an **object** (`{type:"readOnly", ...}`). Different encodings — model both.
|
|
20
|
+
- `turn/steer` requires `expectedTurnId` (from `turn/started`).
|
|
21
|
+
- `thread/list` results are under `result.data` (not `result.threads`); `useStateDbOnly:true` reads the persisted store.
|
|
22
|
+
- `thread/resume` of a *persisted* thread yields live event fan-out; pre-persistence it errors `no rollout found`.
|
|
23
|
+
- Approvals are server→client requests: lane emits `thread/status/changed` `activeFlags:["waitingOnApproval"]`; reply `{id, result:{decision}}` (`accept`/`acceptForSession`/`decline`/`cancel`); server emits `serverRequest/resolved`. File-change approvals carry **no diff** — correlate by `itemId` to the `fileChange` item.
|
|
24
|
+
- Threads persist by default (`ephemeral:false`). Pass `ephemeral:true` for throwaway/test lanes.
|
|
25
|
+
|
|
26
|
+
## Discipline
|
|
27
|
+
|
|
28
|
+
- Pin the binary; regenerate wire models from `codex app-server generate-json-schema` for that version. Do NOT depend on the `openai-codex` Python SDK (it pins an older CLI).
|
|
29
|
+
- No business logic here — this layer is transport + typed primitives only. Orchestration lives in `core/`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed async App Server client (importable standalone, no daemon dependency).
|
|
2
|
+
|
|
3
|
+
The only layer that spawns or speaks to ``codex app-server``. Exposes the typed
|
|
4
|
+
primitives, the normalized ``LaneEvent`` vocabulary, and the client exceptions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .client import AppServerClient
|
|
10
|
+
from .errors import AppServerError, ClientError, ProtocolError, TransportError
|
|
11
|
+
from .events import (
|
|
12
|
+
ApprovalRequested,
|
|
13
|
+
DiffUpdated,
|
|
14
|
+
ItemCompleted,
|
|
15
|
+
LaneEvent,
|
|
16
|
+
LaneIdle,
|
|
17
|
+
StatusChanged,
|
|
18
|
+
TokenUsageUpdated,
|
|
19
|
+
TurnCompleted,
|
|
20
|
+
TurnFailed,
|
|
21
|
+
TurnStarted,
|
|
22
|
+
)
|
|
23
|
+
from .models import InitializeResult, SandboxPolicy, ThreadInfo
|
|
24
|
+
from .transport import StdioTransport, Transport
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AppServerClient",
|
|
28
|
+
"AppServerError",
|
|
29
|
+
"ApprovalRequested",
|
|
30
|
+
"ClientError",
|
|
31
|
+
"DiffUpdated",
|
|
32
|
+
"InitializeResult",
|
|
33
|
+
"ItemCompleted",
|
|
34
|
+
"LaneEvent",
|
|
35
|
+
"LaneIdle",
|
|
36
|
+
"ProtocolError",
|
|
37
|
+
"SandboxPolicy",
|
|
38
|
+
"StatusChanged",
|
|
39
|
+
"StdioTransport",
|
|
40
|
+
"ThreadInfo",
|
|
41
|
+
"TokenUsageUpdated",
|
|
42
|
+
"Transport",
|
|
43
|
+
"TransportError",
|
|
44
|
+
"TurnCompleted",
|
|
45
|
+
"TurnFailed",
|
|
46
|
+
"TurnStarted",
|
|
47
|
+
]
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""The App Server client facade.
|
|
2
|
+
|
|
3
|
+
Transport + router + typed primitives. The ONLY place that speaks the App Server
|
|
4
|
+
protocol (``.claude/rules/client.md``). Importable standalone (no daemon). No
|
|
5
|
+
business logic here — orchestration lives in ``core/``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Self
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from .errors import ProtocolError, TransportError
|
|
19
|
+
from .events import LaneEvent
|
|
20
|
+
from .models import (
|
|
21
|
+
ApprovalPolicy,
|
|
22
|
+
ApprovalsReviewer,
|
|
23
|
+
ClientInfo,
|
|
24
|
+
Decision,
|
|
25
|
+
Effort,
|
|
26
|
+
InitializeParams,
|
|
27
|
+
InitializeResult,
|
|
28
|
+
InjectItemsParams,
|
|
29
|
+
Personality,
|
|
30
|
+
ReasoningSummary,
|
|
31
|
+
SandboxPolicy,
|
|
32
|
+
TextInput,
|
|
33
|
+
ThreadArchiveParams,
|
|
34
|
+
ThreadCompactStartParams,
|
|
35
|
+
ThreadForkParams,
|
|
36
|
+
ThreadGoal,
|
|
37
|
+
ThreadGoalClearParams,
|
|
38
|
+
ThreadGoalGetParams,
|
|
39
|
+
ThreadGoalGetResult,
|
|
40
|
+
ThreadGoalResult,
|
|
41
|
+
ThreadGoalSetParams,
|
|
42
|
+
ThreadGoalStatus,
|
|
43
|
+
ThreadInfo,
|
|
44
|
+
ThreadListParams,
|
|
45
|
+
ThreadListResult,
|
|
46
|
+
ThreadReadParams,
|
|
47
|
+
ThreadResult,
|
|
48
|
+
ThreadResumeParams,
|
|
49
|
+
ThreadRollbackParams,
|
|
50
|
+
ThreadSandbox,
|
|
51
|
+
ThreadSetNameParams,
|
|
52
|
+
ThreadStartParams,
|
|
53
|
+
TurnInterruptParams,
|
|
54
|
+
TurnStartParams,
|
|
55
|
+
TurnSteerParams,
|
|
56
|
+
)
|
|
57
|
+
from .router import Router
|
|
58
|
+
from .transport import Transport
|
|
59
|
+
|
|
60
|
+
_DEFAULT_CLIENT_INFO = ClientInfo(name="dispatch", version="0")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dump(model: BaseModel) -> dict[str, object]:
|
|
64
|
+
# All params are WireModel/BaseModel; serialize with aliases, drop None.
|
|
65
|
+
return model.model_dump(by_alias=True, exclude_none=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AppServerClient:
|
|
69
|
+
"""Typed async client over one app-server connection."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, transport: Transport) -> None:
|
|
72
|
+
self._transport = transport
|
|
73
|
+
self._router = Router()
|
|
74
|
+
self._id = 0
|
|
75
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
76
|
+
self._closed = False
|
|
77
|
+
self._closed_event = asyncio.Event() # set when the read loop ends (EOF or close)
|
|
78
|
+
|
|
79
|
+
async def start(self) -> None:
|
|
80
|
+
"""Begin consuming the transport. Idempotent."""
|
|
81
|
+
if self._read_task is None:
|
|
82
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
83
|
+
|
|
84
|
+
async def __aenter__(self) -> Self:
|
|
85
|
+
await self.start()
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
async def __aexit__(
|
|
89
|
+
self,
|
|
90
|
+
exc_type: type[BaseException] | None,
|
|
91
|
+
exc: BaseException | None,
|
|
92
|
+
tb: TracebackType | None,
|
|
93
|
+
) -> None:
|
|
94
|
+
await self.close()
|
|
95
|
+
|
|
96
|
+
async def _read_loop(self) -> None:
|
|
97
|
+
try:
|
|
98
|
+
try:
|
|
99
|
+
while True:
|
|
100
|
+
message = await self._transport.receive()
|
|
101
|
+
if message is None:
|
|
102
|
+
break
|
|
103
|
+
self._router.handle(message)
|
|
104
|
+
except (TransportError, ProtocolError) as exc:
|
|
105
|
+
self._router.fail_all(exc)
|
|
106
|
+
return
|
|
107
|
+
self._router.fail_all(TransportError("app-server stream closed (stdout EOF)"))
|
|
108
|
+
finally:
|
|
109
|
+
self._closed_event.set() # wake supervisors waiting on wait_closed()
|
|
110
|
+
|
|
111
|
+
async def wait_closed(self) -> None:
|
|
112
|
+
"""Block until the read loop ends — EOF (app-server crash) or ``close()``."""
|
|
113
|
+
await self._closed_event.wait()
|
|
114
|
+
|
|
115
|
+
def _next_id(self) -> int:
|
|
116
|
+
self._id += 1
|
|
117
|
+
return self._id
|
|
118
|
+
|
|
119
|
+
async def _request(
|
|
120
|
+
self, method: str, params: dict[str, object] | None = None
|
|
121
|
+
) -> dict[str, object]:
|
|
122
|
+
request_id = self._next_id()
|
|
123
|
+
fut = self._router.new_request(request_id)
|
|
124
|
+
message: dict[str, object] = {"id": request_id, "method": method}
|
|
125
|
+
if params is not None:
|
|
126
|
+
message["params"] = params
|
|
127
|
+
await self._transport.send(message)
|
|
128
|
+
try:
|
|
129
|
+
return await fut
|
|
130
|
+
except asyncio.CancelledError:
|
|
131
|
+
# A bounded caller (e.g. attach's wait_for) timed out: drop the pending
|
|
132
|
+
# request so abandoned ids don't accumulate in the router.
|
|
133
|
+
self._router.discard_request(request_id)
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
async def _notify(self, method: str, params: dict[str, object] | None = None) -> None:
|
|
137
|
+
message: dict[str, object] = {"method": method}
|
|
138
|
+
if params is not None:
|
|
139
|
+
message["params"] = params
|
|
140
|
+
await self._transport.send(message)
|
|
141
|
+
|
|
142
|
+
# --- lifecycle ------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
async def initialize(
|
|
145
|
+
self,
|
|
146
|
+
client_info: ClientInfo = _DEFAULT_CLIENT_INFO,
|
|
147
|
+
capabilities: dict[str, bool] | None = None,
|
|
148
|
+
) -> InitializeResult:
|
|
149
|
+
params = InitializeParams(
|
|
150
|
+
client_info=client_info,
|
|
151
|
+
capabilities=capabilities if capabilities is not None else {"experimentalApi": False},
|
|
152
|
+
)
|
|
153
|
+
result = await self._request("initialize", _dump(params))
|
|
154
|
+
await self._notify("initialized", {})
|
|
155
|
+
return InitializeResult.model_validate(result)
|
|
156
|
+
|
|
157
|
+
# --- threads --------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def thread_start(
|
|
160
|
+
self,
|
|
161
|
+
cwd: str | None,
|
|
162
|
+
sandbox: ThreadSandbox = "read-only",
|
|
163
|
+
approval_policy: ApprovalPolicy = "never",
|
|
164
|
+
approvals_reviewer: ApprovalsReviewer | None = None,
|
|
165
|
+
base_instructions: str | None = None,
|
|
166
|
+
developer_instructions: str | None = None,
|
|
167
|
+
personality: Personality | None = None,
|
|
168
|
+
service_tier: str | None = None,
|
|
169
|
+
model: str | None = None,
|
|
170
|
+
model_provider: str | None = None,
|
|
171
|
+
ephemeral: bool = False,
|
|
172
|
+
) -> ThreadInfo:
|
|
173
|
+
params = ThreadStartParams(
|
|
174
|
+
cwd=cwd,
|
|
175
|
+
sandbox=sandbox,
|
|
176
|
+
approval_policy=approval_policy,
|
|
177
|
+
approvals_reviewer=approvals_reviewer,
|
|
178
|
+
base_instructions=base_instructions,
|
|
179
|
+
developer_instructions=developer_instructions,
|
|
180
|
+
personality=personality,
|
|
181
|
+
service_tier=service_tier,
|
|
182
|
+
model=model,
|
|
183
|
+
model_provider=model_provider,
|
|
184
|
+
ephemeral=ephemeral,
|
|
185
|
+
)
|
|
186
|
+
result = await self._request("thread/start", _dump(params))
|
|
187
|
+
return ThreadResult.model_validate(result).thread
|
|
188
|
+
|
|
189
|
+
async def thread_resume(self, thread_id: str) -> ThreadInfo:
|
|
190
|
+
result = await self._request(
|
|
191
|
+
"thread/resume", _dump(ThreadResumeParams(thread_id=thread_id))
|
|
192
|
+
)
|
|
193
|
+
return ThreadResult.model_validate(result).thread
|
|
194
|
+
|
|
195
|
+
async def thread_fork(
|
|
196
|
+
self,
|
|
197
|
+
thread_id: str,
|
|
198
|
+
*,
|
|
199
|
+
cwd: str | None = None,
|
|
200
|
+
sandbox: ThreadSandbox | None = None,
|
|
201
|
+
approval_policy: ApprovalPolicy | None = None,
|
|
202
|
+
approvals_reviewer: ApprovalsReviewer | None = None,
|
|
203
|
+
base_instructions: str | None = None,
|
|
204
|
+
developer_instructions: str | None = None,
|
|
205
|
+
service_tier: str | None = None,
|
|
206
|
+
model: str | None = None,
|
|
207
|
+
model_provider: str | None = None,
|
|
208
|
+
ephemeral: bool = False,
|
|
209
|
+
) -> ThreadInfo:
|
|
210
|
+
params = ThreadForkParams(
|
|
211
|
+
thread_id=thread_id,
|
|
212
|
+
cwd=cwd,
|
|
213
|
+
sandbox=sandbox,
|
|
214
|
+
approval_policy=approval_policy,
|
|
215
|
+
approvals_reviewer=approvals_reviewer,
|
|
216
|
+
base_instructions=base_instructions,
|
|
217
|
+
developer_instructions=developer_instructions,
|
|
218
|
+
service_tier=service_tier,
|
|
219
|
+
model=model,
|
|
220
|
+
model_provider=model_provider,
|
|
221
|
+
ephemeral=ephemeral,
|
|
222
|
+
)
|
|
223
|
+
result = await self._request("thread/fork", _dump(params))
|
|
224
|
+
return ThreadResult.model_validate(result).thread
|
|
225
|
+
|
|
226
|
+
async def thread_list(
|
|
227
|
+
self, limit: int = 50, cursor: str | None = None, use_state_db_only: bool | None = None
|
|
228
|
+
) -> list[ThreadInfo]:
|
|
229
|
+
params = ThreadListParams(limit=limit, cursor=cursor, use_state_db_only=use_state_db_only)
|
|
230
|
+
result = await self._request("thread/list", _dump(params))
|
|
231
|
+
return ThreadListResult.model_validate(result).data
|
|
232
|
+
|
|
233
|
+
async def thread_read(self, thread_id: str, include_turns: bool = False) -> dict[str, object]:
|
|
234
|
+
return await self._request(
|
|
235
|
+
"thread/read", _dump(ThreadReadParams(thread_id=thread_id, include_turns=include_turns))
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def thread_archive(self, thread_id: str) -> None:
|
|
239
|
+
await self._request("thread/archive", _dump(ThreadArchiveParams(thread_id=thread_id)))
|
|
240
|
+
|
|
241
|
+
async def thread_set_name(self, thread_id: str, name: str) -> None:
|
|
242
|
+
await self._request(
|
|
243
|
+
"thread/name/set", _dump(ThreadSetNameParams(thread_id=thread_id, name=name))
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def thread_rollback(self, thread_id: str, num_turns: int) -> ThreadInfo:
|
|
247
|
+
result = await self._request(
|
|
248
|
+
"thread/rollback", _dump(ThreadRollbackParams(thread_id=thread_id, num_turns=num_turns))
|
|
249
|
+
)
|
|
250
|
+
return ThreadResult.model_validate(result).thread
|
|
251
|
+
|
|
252
|
+
async def thread_compact_start(self, thread_id: str) -> None:
|
|
253
|
+
await self._request(
|
|
254
|
+
"thread/compact/start", _dump(ThreadCompactStartParams(thread_id=thread_id))
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def thread_goal_get(self, thread_id: str) -> ThreadGoal | None:
|
|
258
|
+
result = await self._request(
|
|
259
|
+
"thread/goal/get", _dump(ThreadGoalGetParams(thread_id=thread_id))
|
|
260
|
+
)
|
|
261
|
+
return ThreadGoalGetResult.model_validate(result).goal
|
|
262
|
+
|
|
263
|
+
async def thread_goal_set(
|
|
264
|
+
self,
|
|
265
|
+
thread_id: str,
|
|
266
|
+
*,
|
|
267
|
+
objective: str | None = None,
|
|
268
|
+
status: ThreadGoalStatus | None = None,
|
|
269
|
+
token_budget: int | None = None,
|
|
270
|
+
) -> ThreadGoal:
|
|
271
|
+
params = ThreadGoalSetParams(
|
|
272
|
+
thread_id=thread_id,
|
|
273
|
+
objective=objective,
|
|
274
|
+
status=status,
|
|
275
|
+
token_budget=token_budget,
|
|
276
|
+
)
|
|
277
|
+
result = await self._request("thread/goal/set", _dump(params))
|
|
278
|
+
return ThreadGoalResult.model_validate(result).goal
|
|
279
|
+
|
|
280
|
+
async def thread_goal_clear(self, thread_id: str) -> None:
|
|
281
|
+
await self._request("thread/goal/clear", _dump(ThreadGoalClearParams(thread_id=thread_id)))
|
|
282
|
+
|
|
283
|
+
# --- turns + injection ----------------------------------------------------
|
|
284
|
+
|
|
285
|
+
async def turn_start(
|
|
286
|
+
self,
|
|
287
|
+
thread_id: str,
|
|
288
|
+
text: str,
|
|
289
|
+
cwd: str,
|
|
290
|
+
approval_policy: ApprovalPolicy = "never",
|
|
291
|
+
approvals_reviewer: ApprovalsReviewer | None = None,
|
|
292
|
+
sandbox_policy: SandboxPolicy | None = None,
|
|
293
|
+
effort: Effort | None = None,
|
|
294
|
+
summary: ReasoningSummary | None = None,
|
|
295
|
+
model: str | None = None,
|
|
296
|
+
output_schema: dict[str, object] | None = None,
|
|
297
|
+
personality: Personality | None = None,
|
|
298
|
+
) -> dict[str, object]:
|
|
299
|
+
params = TurnStartParams(
|
|
300
|
+
thread_id=thread_id,
|
|
301
|
+
input=[TextInput(text=text)],
|
|
302
|
+
cwd=cwd,
|
|
303
|
+
approval_policy=approval_policy,
|
|
304
|
+
approvals_reviewer=approvals_reviewer,
|
|
305
|
+
sandbox_policy=sandbox_policy if sandbox_policy is not None else SandboxPolicy(),
|
|
306
|
+
effort=effort,
|
|
307
|
+
summary=summary,
|
|
308
|
+
model=model,
|
|
309
|
+
output_schema=output_schema,
|
|
310
|
+
personality=personality,
|
|
311
|
+
)
|
|
312
|
+
return await self._request("turn/start", _dump(params))
|
|
313
|
+
|
|
314
|
+
async def turn_steer(
|
|
315
|
+
self, thread_id: str, expected_turn_id: str, text: str
|
|
316
|
+
) -> dict[str, object]:
|
|
317
|
+
params = TurnSteerParams(
|
|
318
|
+
thread_id=thread_id, expected_turn_id=expected_turn_id, input=[TextInput(text=text)]
|
|
319
|
+
)
|
|
320
|
+
return await self._request("turn/steer", _dump(params))
|
|
321
|
+
|
|
322
|
+
async def turn_interrupt(self, thread_id: str, turn_id: str) -> None:
|
|
323
|
+
await self._request(
|
|
324
|
+
"turn/interrupt", _dump(TurnInterruptParams(thread_id=thread_id, turn_id=turn_id))
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def inject_items(self, thread_id: str, items: list[dict[str, object]]) -> None:
|
|
328
|
+
await self._request(
|
|
329
|
+
"thread/inject_items", _dump(InjectItemsParams(thread_id=thread_id, items=items))
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# --- approvals ------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
async def respond_approval(self, request_id: int, decision: Decision) -> None:
|
|
335
|
+
"""Reply to a server->client approval request on the same stream."""
|
|
336
|
+
await self._transport.send({"id": request_id, "result": {"decision": decision}})
|
|
337
|
+
|
|
338
|
+
# --- event streams --------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def events(self, lane: str | None = None) -> AsyncIterator[LaneEvent]:
|
|
341
|
+
"""Normalized LaneEvents for one lane, or all lanes when ``lane`` is None."""
|
|
342
|
+
return self._router.events.subscribe(lane)
|
|
343
|
+
|
|
344
|
+
def raw_events(self, lane: str | None = None) -> AsyncIterator[dict[str, object]]:
|
|
345
|
+
"""Raw notifications/server-requests for one lane (content for ``show``)."""
|
|
346
|
+
return self._router.raw.subscribe(lane)
|
|
347
|
+
|
|
348
|
+
async def close(self) -> None:
|
|
349
|
+
if self._closed:
|
|
350
|
+
return
|
|
351
|
+
self._closed = True
|
|
352
|
+
# Fail any in-flight requests and close the event/raw streams up front, so
|
|
353
|
+
# callers never deadlock if close() wins the race against stdout EOF.
|
|
354
|
+
self._router.fail_all(TransportError("client closed"))
|
|
355
|
+
if self._read_task is not None:
|
|
356
|
+
self._read_task.cancel()
|
|
357
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
358
|
+
await self._read_task
|
|
359
|
+
await self._transport.close()
|
|
360
|
+
self._closed_event.set()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Client-layer exceptions.
|
|
2
|
+
|
|
3
|
+
The ``client/`` package is importable standalone (no daemon/contracts
|
|
4
|
+
dependency, per ``.claude/rules/client.md``), so it defines its own minimal
|
|
5
|
+
exception family. Phase 2's ``contracts/errors.py`` (the ``DispatchError``
|
|
6
|
+
taxonomy, ADR-0001) maps these into surface-projected errors at the handler
|
|
7
|
+
boundary; nothing here imports from ``contracts``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClientError(Exception):
|
|
14
|
+
"""Base class for all App Server client errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TransportError(ClientError):
|
|
18
|
+
"""The app-server subprocess/stream failed (spawn failure, stdout EOF, write
|
|
19
|
+
to a dead process)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProtocolError(ClientError):
|
|
23
|
+
"""A wire message was malformed or violated the expected JSON-RPC-lite shape."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AppServerError(ClientError):
|
|
27
|
+
"""The app-server returned a JSON-RPC error response to one of our requests."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, code: int, message: str, data: object | None = None) -> None:
|
|
30
|
+
super().__init__(f"app-server error {code}: {message}")
|
|
31
|
+
self.code = code
|
|
32
|
+
self.message = message
|
|
33
|
+
self.data = data
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Normalized internal LaneEvent vocabulary + projection (ADR-0007).
|
|
2
|
+
|
|
3
|
+
The client layer is the single point that turns raw App Server notifications and
|
|
4
|
+
server-requests into typed ``LaneEvent``s. The reactor, triggers, and the
|
|
5
|
+
conditional-guard seam operate ONLY on these — never on raw protocol dicts — so
|
|
6
|
+
they stay stable across binary/protocol drift.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
ApprovalKind = Literal["command", "file_change"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class LaneEvent:
|
|
19
|
+
"""Base for all normalized lane events. ``lane_id`` is the App Server threadId."""
|
|
20
|
+
|
|
21
|
+
lane_id: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TurnStarted(LaneEvent):
|
|
26
|
+
turn_id: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TurnCompleted(LaneEvent):
|
|
31
|
+
turn_id: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class TurnFailed(LaneEvent):
|
|
36
|
+
turn_id: str | None = None
|
|
37
|
+
message: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class LaneIdle(LaneEvent):
|
|
42
|
+
"""Derived: the lane has no active flags (computed here, not per-consumer)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ApprovalRequested(LaneEvent):
|
|
47
|
+
"""A server->client approval request. ``request_id`` is the JSON-RPC id to
|
|
48
|
+
reply on; file-change requests carry no diff (correlate by ``item_id``)."""
|
|
49
|
+
|
|
50
|
+
request_id: int
|
|
51
|
+
kind: ApprovalKind
|
|
52
|
+
item_id: str | None = None
|
|
53
|
+
turn_id: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ItemCompleted(LaneEvent):
|
|
58
|
+
item_id: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class DiffUpdated(LaneEvent):
|
|
63
|
+
turn_id: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class StatusChanged(LaneEvent):
|
|
68
|
+
active_flags: tuple[str, ...] = ()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class TokenUsageUpdated(LaneEvent):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class GoalUpdated(LaneEvent):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class GoalCleared(LaneEvent):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ThreadCompacted(LaneEvent):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _thread_id(params: dict[str, object]) -> str | None:
|
|
92
|
+
tid = params.get("threadId")
|
|
93
|
+
return tid if isinstance(tid, str) else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _str(params: dict[str, object], key: str) -> str | None:
|
|
97
|
+
val = params.get(key)
|
|
98
|
+
return val if isinstance(val, str) else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def project_notification(method: str, params: dict[str, object]) -> list[LaneEvent]:
|
|
102
|
+
"""Project a server->client notification into zero or more LaneEvents.
|
|
103
|
+
|
|
104
|
+
Unknown methods project to ``[]`` (ignored), keeping triggers decoupled from
|
|
105
|
+
the long tail of protocol notifications.
|
|
106
|
+
"""
|
|
107
|
+
lane = _thread_id(params)
|
|
108
|
+
if lane is None:
|
|
109
|
+
return []
|
|
110
|
+
turn = _str(params, "turnId")
|
|
111
|
+
match method:
|
|
112
|
+
case "turn/started":
|
|
113
|
+
return [TurnStarted(lane, turn)]
|
|
114
|
+
case "turn/completed":
|
|
115
|
+
return [TurnCompleted(lane, turn)]
|
|
116
|
+
case "turn/failed":
|
|
117
|
+
return [TurnFailed(lane, turn, _str(params, "message"))]
|
|
118
|
+
case "turn/diff/updated":
|
|
119
|
+
return [DiffUpdated(lane, turn)]
|
|
120
|
+
case "item/completed":
|
|
121
|
+
return [ItemCompleted(lane, _str(params, "itemId"))]
|
|
122
|
+
case "thread/tokenUsage/updated":
|
|
123
|
+
return [TokenUsageUpdated(lane)]
|
|
124
|
+
case "thread/goal/updated":
|
|
125
|
+
return [GoalUpdated(lane)]
|
|
126
|
+
case "thread/goal/cleared":
|
|
127
|
+
return [GoalCleared(lane)]
|
|
128
|
+
case "thread/compacted":
|
|
129
|
+
return [ThreadCompacted(lane)]
|
|
130
|
+
case "thread/status/changed":
|
|
131
|
+
flags = _active_flags(params)
|
|
132
|
+
events: list[LaneEvent] = [StatusChanged(lane, flags)]
|
|
133
|
+
if not flags:
|
|
134
|
+
events.append(LaneIdle(lane))
|
|
135
|
+
return events
|
|
136
|
+
case _:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def project_server_request(
|
|
141
|
+
request_id: int, method: str, params: dict[str, object]
|
|
142
|
+
) -> LaneEvent | None:
|
|
143
|
+
"""Project a server->client request (approvals) into a LaneEvent, or None."""
|
|
144
|
+
lane = _thread_id(params)
|
|
145
|
+
if lane is None:
|
|
146
|
+
return None
|
|
147
|
+
item = _str(params, "itemId")
|
|
148
|
+
turn = _str(params, "turnId")
|
|
149
|
+
match method:
|
|
150
|
+
case "item/commandExecution/requestApproval":
|
|
151
|
+
return ApprovalRequested(lane, request_id, "command", item, turn)
|
|
152
|
+
case "item/fileChange/requestApproval":
|
|
153
|
+
return ApprovalRequested(lane, request_id, "file_change", item, turn)
|
|
154
|
+
case _:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _active_flags(params: dict[str, object]) -> tuple[str, ...]:
|
|
159
|
+
status = params.get("status")
|
|
160
|
+
flags = status.get("activeFlags") if isinstance(status, dict) else params.get("activeFlags")
|
|
161
|
+
if isinstance(flags, list):
|
|
162
|
+
return tuple(f for f in flags if isinstance(f, str))
|
|
163
|
+
return ()
|