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.
Files changed (49) hide show
  1. outfitter/dispatch/__init__.py +6 -0
  2. outfitter/dispatch/cli.py +16 -0
  3. outfitter/dispatch/client/AGENTS.md +29 -0
  4. outfitter/dispatch/client/__init__.py +47 -0
  5. outfitter/dispatch/client/client.py +360 -0
  6. outfitter/dispatch/client/errors.py +33 -0
  7. outfitter/dispatch/client/events.py +163 -0
  8. outfitter/dispatch/client/models.py +247 -0
  9. outfitter/dispatch/client/router.py +148 -0
  10. outfitter/dispatch/client/transport.py +123 -0
  11. outfitter/dispatch/config.py +34 -0
  12. outfitter/dispatch/contracts/AGENTS.md +54 -0
  13. outfitter/dispatch/contracts/__init__.py +43 -0
  14. outfitter/dispatch/contracts/context.py +144 -0
  15. outfitter/dispatch/contracts/derive_cli.py +388 -0
  16. outfitter/dispatch/contracts/derive_mcp.py +191 -0
  17. outfitter/dispatch/contracts/errors.py +101 -0
  18. outfitter/dispatch/contracts/examples.py +51 -0
  19. outfitter/dispatch/contracts/execute.py +27 -0
  20. outfitter/dispatch/contracts/op.py +75 -0
  21. outfitter/dispatch/contracts/registry.py +38 -0
  22. outfitter/dispatch/core/__init__.py +2 -0
  23. outfitter/dispatch/core/handlers.py +631 -0
  24. outfitter/dispatch/core/models.py +331 -0
  25. outfitter/dispatch/core/new_config.py +255 -0
  26. outfitter/dispatch/core/ops.py +403 -0
  27. outfitter/dispatch/core/queue.py +59 -0
  28. outfitter/dispatch/core/reactor.py +93 -0
  29. outfitter/dispatch/core/scheduler.py +79 -0
  30. outfitter/dispatch/core/trigger_handlers.py +118 -0
  31. outfitter/dispatch/core/triggers.py +102 -0
  32. outfitter/dispatch/daemon/__init__.py +6 -0
  33. outfitter/dispatch/daemon/__main__.py +61 -0
  34. outfitter/dispatch/daemon/control.py +94 -0
  35. outfitter/dispatch/daemon/host.py +79 -0
  36. outfitter/dispatch/daemon/lifecycle.py +103 -0
  37. outfitter/dispatch/daemon/supervisor.py +94 -0
  38. outfitter/dispatch/registry/__init__.py +2 -0
  39. outfitter/dispatch/registry/models.py +121 -0
  40. outfitter/dispatch/registry/store.py +380 -0
  41. outfitter/dispatch/surfaces/AGENTS.md +24 -0
  42. outfitter/dispatch/surfaces/__init__.py +5 -0
  43. outfitter/dispatch/surfaces/cli.py +122 -0
  44. outfitter/dispatch/surfaces/mcp.py +137 -0
  45. outfitter/dispatch/version.py +15 -0
  46. outfitter_dispatch-0.1.0.dist-info/METADATA +87 -0
  47. outfitter_dispatch-0.1.0.dist-info/RECORD +49 -0
  48. outfitter_dispatch-0.1.0.dist-info/WHEEL +4 -0
  49. outfitter_dispatch-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,6 @@
1
+ """outfitter.dispatch — local control plane for orchestrating Codex agent lanes.
2
+
3
+ PEP 420 namespace package: there is intentionally no ``__init__.py`` at the
4
+ ``src/outfitter/`` level. The importable package starts here, at
5
+ ``outfitter.dispatch``.
6
+ """
@@ -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 ()