agent-decoder 0.3.2__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 (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
@@ -0,0 +1,387 @@
1
+ """DriverManager — session-keyed owner of live ``ProviderDriver`` instances.
2
+
3
+ The driver-side analogue of ``PtyManager``: it owns one ``ProviderDriver`` per
4
+ driven session, fans the driver's output bytes onto the same SSE shapes the
5
+ Native transport already consumes (``pty_output_chunk`` / ``pty_native_state``),
6
+ and serves Native-view hydration snapshots — so the existing provider-agnostic
7
+ xterm.js renderer can drive a Codex session with no renderer changes.
8
+
9
+ What it deliberately does NOT have: an idle reaper. tmux sessions are meant to
10
+ **survive disconnect and idle** (that is the whole point of the tmux backend —
11
+ it fixes "the 5-minute reaper kills long tasks"). Sessions end only on an
12
+ explicit ``kill`` or clean ``shutdown``. Codex sessions never enter
13
+ ``PtyManager``, so they are structurally excluded from its disconnect-kill.
14
+
15
+ Claude is intentionally never routed here; it keeps its tuned direct-PTY path.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ from typing import Any
23
+
24
+ from .db import Database
25
+ from .drivers import DriverState, ProviderDriver, build_driver
26
+ from .events_bus import EventBroadcaster
27
+ from .pty_native import encode_pty_output_chunk, encode_pty_snapshot
28
+ from .pty_runner import DEFAULT_COLS, DEFAULT_ROWS
29
+
30
+ _log = logging.getLogger(__name__)
31
+
32
+
33
+ # How often the background state poller scrapes the active driver's screen.
34
+ # Only the ONE active session is polled, so this is ~60 tmux capture-pane
35
+ # forks/min at worst (~2–8 ms each) — cheap relative to the SSE round-trip.
36
+ # Codex's TUI doesn't fire an output chunk when it lands on an approval prompt
37
+ # it drew on its own (it repaints in place), so unlike Claude we can't lean on
38
+ # the output fan-out to reclassify state: we have to poll. See
39
+ # docs/native-input-required-plan.md (Part A).
40
+ STATE_POLL_INTERVAL_S = 1.0
41
+
42
+
43
+ # DriverState → the native_state vocabulary the FE already understands
44
+ # (see pty_screen_state.classify_screen). Keeps the Native view's existing
45
+ # state handling working for Codex with zero FE changes.
46
+ _STATE_TO_NATIVE: dict[DriverState, str] = {
47
+ DriverState.IDLE: "idle_chat_input",
48
+ DriverState.RUNNING: "running",
49
+ DriverState.NEEDS_TRUST: "trust_prompt",
50
+ DriverState.NEEDS_APPROVAL: "permission_prompt",
51
+ DriverState.NEEDS_LOGIN: "login_required",
52
+ DriverState.NEEDS_UPDATE: "permission_prompt",
53
+ DriverState.STARTING: "idle_chat_input",
54
+ DriverState.DEAD: "dead",
55
+ }
56
+
57
+
58
+ class DriverManager:
59
+ """Owns ``ProviderDriver`` instances keyed by session id.
60
+
61
+ Mirrors the slice of ``PtyManager``'s surface the pty endpoints use
62
+ (``focus``/``submit``/``write_raw_input``/``resize``/``native_snapshot``/
63
+ ``status``/``kill``/``shutdown``) so the server can route driver-backed
64
+ providers here with a thin provider branch.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ db: Database,
70
+ bus: EventBroadcaster,
71
+ *,
72
+ ui_endpoint: str | None = None,
73
+ ) -> None:
74
+ self._db = db
75
+ self._bus = bus
76
+ self._ui_endpoint = ui_endpoint
77
+ self._drivers: dict[str, ProviderDriver] = {}
78
+ self._providers: dict[str, str] = {}
79
+ self._session_locks: dict[str, asyncio.Lock] = {}
80
+ self._native_cols = DEFAULT_COLS
81
+ # Only the on-screen session's output is broadcast over SSE — mirrors
82
+ # PtyManager, so N background drivers don't firehose the main thread.
83
+ self._active_session_id: str | None = None
84
+ # Last (state, decoded_input_safe) the poller published per session —
85
+ # mirrors PtyManager's _last_native_state (pty_runner.py:1361) so a
86
+ # steady-state screen (e.g. still on the same approval prompt) emits
87
+ # once, not once per poll tick.
88
+ self._last_driver_state: dict[str, tuple[str, bool]] = {}
89
+ # Serialises poller capture-pane calls. Kept SEPARATE from the
90
+ # per-session _session_locks so a slow capture-pane can't block
91
+ # submit/resize/write_raw_input on the same session.
92
+ self._state_poll_lock = asyncio.Lock()
93
+ # One long-lived background task started on first focus(); cancelled
94
+ # in shutdown(). Gates emission to the active session only.
95
+ self._state_poll_task: asyncio.Task[None] | None = None
96
+
97
+ # ------------------------------------------------------------------
98
+ # Config / locking
99
+ # ------------------------------------------------------------------
100
+
101
+ def set_native_cols(self, cols: int) -> None:
102
+ if cols > 0:
103
+ self._native_cols = cols
104
+
105
+ def _session_lock(self, session_id: str) -> asyncio.Lock:
106
+ lock = self._session_locks.get(session_id)
107
+ if lock is None:
108
+ lock = asyncio.Lock()
109
+ self._session_locks[session_id] = lock
110
+ return lock
111
+
112
+ def has(self, session_id: str) -> bool:
113
+ return session_id in self._drivers
114
+
115
+ def is_alive(self, session_id: str) -> bool:
116
+ d = self._drivers.get(session_id)
117
+ return d is not None and d.is_alive()
118
+
119
+ # ------------------------------------------------------------------
120
+ # State poller
121
+ # ------------------------------------------------------------------
122
+
123
+ def _ensure_state_poller(self) -> None:
124
+ """Start the background state poller once (idempotent).
125
+
126
+ Without this, codex's blocked state (approval/trust/login prompt) is
127
+ only classified inside ``native_snapshot()`` — which the FE calls only
128
+ when the Native pane mounts. A codex session sitting at an approval
129
+ prompt in Decoded-only is therefore invisible. The poller reuses
130
+ ``capture_state()`` + ``_STATE_TO_NATIVE`` and emits ``pty_native_state``
131
+ on change, gated to the single active session — mirroring how
132
+ ``PtyManager`` already classifies claude on every output chunk.
133
+ """
134
+ if self._state_poll_task is not None and not self._state_poll_task.done():
135
+ return
136
+ self._state_poll_task = asyncio.create_task(self._state_poll_loop())
137
+
138
+ async def _state_poll_loop(self) -> None:
139
+ """Poll the active driver's screen and emit ``pty_native_state`` on change.
140
+
141
+ Only the active session is polled; a None/missing/dead active driver is
142
+ skipped (death is already emitted by ``_make_on_dead``). Emits are
143
+ deduped on ``(state, decoded_input_safe)`` so a steady prompt emits
144
+ once. The ``capture_state`` call runs under ``_state_poll_lock`` so its
145
+ tmux fork can't overlap itself, but NOT under ``_session_lock`` so it
146
+ can't stall submit/resize.
147
+ """
148
+ while True:
149
+ await asyncio.sleep(STATE_POLL_INTERVAL_S)
150
+ sid = self._active_session_id
151
+ if sid is None:
152
+ continue
153
+ driver = self._drivers.get(sid)
154
+ if driver is None or not driver.is_alive():
155
+ continue
156
+ try:
157
+ async with self._state_poll_lock:
158
+ state = await driver.capture_state()
159
+ native_state = _STATE_TO_NATIVE.get(state, "idle_chat_input")
160
+ key = (native_state, state == DriverState.IDLE)
161
+ if self._last_driver_state.get(sid) == key:
162
+ continue
163
+ self._last_driver_state[sid] = key
164
+ self._bus.publish(
165
+ {
166
+ "type": "pty_native_state",
167
+ "session_id": sid,
168
+ "state": native_state,
169
+ "decoded_input_safe": key[1],
170
+ }
171
+ )
172
+ except Exception as exc: # pragma: no cover — defensive
173
+ _log.warning("driver: state poll raised (session %s): %s", sid, exc)
174
+
175
+ # ------------------------------------------------------------------
176
+ # Output fanout
177
+ # ------------------------------------------------------------------
178
+
179
+ def _make_on_chunk(self, session_id: str):
180
+ def _cb(chunk: bytes) -> None:
181
+ # Drop output for backgrounded sessions — only the active session's
182
+ # bytes reach the browser.
183
+ if session_id != self._active_session_id:
184
+ return
185
+ try:
186
+ self._bus.publish(encode_pty_output_chunk(session_id, chunk))
187
+ except Exception as exc: # pragma: no cover — defensive
188
+ _log.warning(
189
+ "driver: output publish raised (session %s): %s", session_id, exc
190
+ )
191
+
192
+ return _cb
193
+
194
+ def _make_on_dead(self, session_id: str):
195
+ def _cb() -> None:
196
+ try:
197
+ self._bus.publish(
198
+ {
199
+ "type": "pty_native_state",
200
+ "session_id": session_id,
201
+ "state": "dead",
202
+ "decoded_input_safe": False,
203
+ }
204
+ )
205
+ except Exception as exc: # pragma: no cover — defensive
206
+ _log.warning(
207
+ "driver: dead publish raised (session %s): %s", session_id, exc
208
+ )
209
+ # Drop the spent driver so a later focus() rebuilds a fresh one.
210
+ # An out-of-band death (codex /quit, crash, tmux killed) leaves the
211
+ # driver with a closed attach client but a stale _attach_proc; if we
212
+ # keep it in _drivers, focus()'s respawn branch calls spawn() on it
213
+ # and raises "already spawned" (HTTP 500) — the session then stays
214
+ # unreopenable until the server restarts. Guard on is_alive() so a
215
+ # concurrently re-spawned driver for the same id is never evicted.
216
+ driver = self._drivers.get(session_id)
217
+ if driver is not None and not driver.is_alive():
218
+ self._drivers.pop(session_id, None)
219
+ self._providers.pop(session_id, None)
220
+ self._last_driver_state.pop(session_id, None)
221
+ if self._active_session_id == session_id:
222
+ self._active_session_id = None
223
+ # Release the leaked master fd / reap the dead attach client
224
+ # off the reader callback (kill-session is idempotent if gone).
225
+ try:
226
+ asyncio.get_running_loop().create_task(driver.kill())
227
+ except RuntimeError: # pragma: no cover — no running loop
228
+ pass
229
+
230
+ return _cb
231
+
232
+ # ------------------------------------------------------------------
233
+ # Lifecycle
234
+ # ------------------------------------------------------------------
235
+
236
+ async def focus(
237
+ self,
238
+ session_id: str,
239
+ *,
240
+ provider: str,
241
+ cwd: str,
242
+ model: str | None = None,
243
+ resume_uuid: str | None = None,
244
+ new_chat: bool = False,
245
+ rows: int | None = None,
246
+ ) -> None:
247
+ """Ensure a live driver for ``session_id`` and mark it active.
248
+
249
+ Spawns on first focus; reattaches if the tmux session outlived the app
250
+ (persistence/reconnect); otherwise re-uses the live driver. ``new_chat``
251
+ spawns the CLI fresh (no resume) — for a brand-new chat whose real
252
+ rollout id the CLI mints itself.
253
+ """
254
+ cols = self._native_cols
255
+ eff_rows = rows if rows is not None else DEFAULT_ROWS
256
+ async with self._session_lock(session_id):
257
+ driver = self._drivers.get(session_id)
258
+ if driver is None:
259
+ driver = build_driver(
260
+ provider,
261
+ session_id,
262
+ cwd,
263
+ model=model,
264
+ resume_uuid=resume_uuid,
265
+ fresh=new_chat,
266
+ on_chunk=self._make_on_chunk(session_id),
267
+ on_dead=self._make_on_dead(session_id),
268
+ rows=eff_rows,
269
+ cols=cols,
270
+ )
271
+ self._drivers[session_id] = driver
272
+ self._providers[session_id] = provider
273
+ if not driver.is_alive():
274
+ # Reattach to a surviving tmux session, else spawn fresh.
275
+ if hasattr(driver, "has_session") and await driver.has_session():
276
+ await driver.attach() # type: ignore[attr-defined]
277
+ else:
278
+ await driver.spawn(cols=cols, rows=eff_rows)
279
+ self._active_session_id = session_id
280
+ # Start (idempotent) the active-session state poller so codex's
281
+ # blocked state is surfaced even when the Native pane never mounts
282
+ # (Decoded-only / Split). No-op if already running.
283
+ self._ensure_state_poller()
284
+
285
+ async def submit(self, session_id: str, content: str) -> None:
286
+ async with self._session_lock(session_id):
287
+ driver = self._drivers.get(session_id)
288
+ if driver is None or not driver.is_alive():
289
+ raise RuntimeError(f"no live driver for session {session_id}")
290
+ await driver.send_text(content)
291
+
292
+ async def write_raw_input(self, session_id: str, data: bytes) -> None:
293
+ driver = self._drivers.get(session_id)
294
+ if driver is None:
295
+ raise RuntimeError(f"no driver for session {session_id}")
296
+ await driver.write_input(data)
297
+
298
+ async def resize(self, session_id: str, rows: int, cols: int) -> None:
299
+ driver = self._drivers.get(session_id)
300
+ if driver is None:
301
+ raise RuntimeError(f"no driver for session {session_id}")
302
+ await driver.resize(cols=cols, rows=rows)
303
+
304
+ async def native_snapshot(self, session_id: str) -> dict[str, Any]:
305
+ driver = self._drivers.get(session_id)
306
+ if driver is None:
307
+ raise RuntimeError(f"no driver for session {session_id}")
308
+ self._active_session_id = session_id
309
+ alive = driver.is_alive()
310
+ if alive:
311
+ state = await driver.capture_state()
312
+ else:
313
+ state = DriverState.DEAD
314
+ native_state = _STATE_TO_NATIVE.get(state, "idle_chat_input")
315
+ rows, cols = driver.dimensions() # type: ignore[attr-defined]
316
+ return encode_pty_snapshot(
317
+ session_id=session_id,
318
+ ring=driver.output_snapshot(),
319
+ ring_complete=driver.output_snapshot_complete(), # type: ignore[attr-defined]
320
+ rows=rows,
321
+ cols=cols,
322
+ alive=alive,
323
+ native_state=native_state,
324
+ decoded_input_safe=(state == DriverState.IDLE),
325
+ )
326
+
327
+ def status(self, session_id: str) -> dict[str, Any]:
328
+ driver = self._drivers.get(session_id)
329
+ if driver is None:
330
+ return {"alive": False, "provider": self._providers.get(session_id)}
331
+ return {
332
+ "alive": driver.is_alive(),
333
+ "provider": self._providers.get(session_id),
334
+ "backend": "tmux",
335
+ }
336
+
337
+ async def kill(self, session_id: str) -> None:
338
+ async with self._session_lock(session_id):
339
+ driver = self._drivers.pop(session_id, None)
340
+ self._providers.pop(session_id, None)
341
+ self._last_driver_state.pop(session_id, None)
342
+ if self._active_session_id == session_id:
343
+ self._active_session_id = None
344
+ if driver is not None:
345
+ await driver.kill()
346
+
347
+ async def rekey(self, old_session_id: str, new_session_id: str) -> bool:
348
+ """Re-key a live driver from a placeholder id to its real id, in place.
349
+
350
+ Adopts the real Codex rollout UUID once codex creates it on the first
351
+ message: the running tmux session is renamed and the driver moves to the
352
+ new key, so a later focus(new_session_id) reuses the SAME live process
353
+ (no respawn, no double-codex-on-one-rollout conflict). Returns True if a
354
+ driver was re-keyed.
355
+ """
356
+ if old_session_id == new_session_id:
357
+ return False
358
+ async with self._session_lock(old_session_id):
359
+ driver = self._drivers.pop(old_session_id, None)
360
+ provider = self._providers.pop(old_session_id, None)
361
+ if driver is None:
362
+ return False
363
+ if hasattr(driver, "rename"):
364
+ await driver.rename(new_session_id) # type: ignore[attr-defined]
365
+ self._drivers[new_session_id] = driver
366
+ if provider is not None:
367
+ self._providers[new_session_id] = provider
368
+ if self._active_session_id == old_session_id:
369
+ self._active_session_id = new_session_id
370
+ return True
371
+
372
+ async def shutdown(self) -> None:
373
+ # Stop the background poller first so it can't capture_state() a
374
+ # driver mid-kill and publish a stale state after shutdown begins.
375
+ task = self._state_poll_task
376
+ self._state_poll_task = None
377
+ if task is not None and not task.done():
378
+ task.cancel()
379
+ try:
380
+ await task
381
+ except (asyncio.CancelledError, Exception): # pragma: no cover
382
+ pass
383
+ for session_id in list(self._drivers.keys()):
384
+ try:
385
+ await self.kill(session_id)
386
+ except Exception as exc: # pragma: no cover — defensive
387
+ _log.warning("driver: shutdown kill raised (%s): %s", session_id, exc)
@@ -0,0 +1,27 @@
1
+ """Live-driving backends for provider CLIs.
2
+
3
+ See ``base.ProviderDriver`` for the seam and ``tmux_driver.TmuxDriver`` for the
4
+ v1 POSIX backend. ``registry`` wires providers to backends and answers runtime
5
+ drivability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base import DriverAvailability, DriverState, ProviderDriver
11
+ from .registry import (
12
+ availability_for,
13
+ build_driver,
14
+ supports_driving,
15
+ )
16
+ from .tmux_driver import TmuxDriver, codex_spawn_builder
17
+
18
+ __all__ = [
19
+ "DriverAvailability",
20
+ "DriverState",
21
+ "ProviderDriver",
22
+ "TmuxDriver",
23
+ "codex_spawn_builder",
24
+ "availability_for",
25
+ "build_driver",
26
+ "supports_driving",
27
+ ]
@@ -0,0 +1,170 @@
1
+ """Provider driver abstraction — the transport seam for *driving* a live CLI.
2
+
3
+ A ``ProviderAdapter`` (``providers/``) reads a provider's on-disk format; a
4
+ ``ProviderDriver`` (this package) *drives* a live process for that provider —
5
+ spawning it, streaming its TUI bytes, sending input, and reporting state.
6
+
7
+ The two are deliberately separate: decode/recall is cross-platform and always
8
+ available, while driving depends on a runtime backend (tmux today, ConPTY
9
+ later). v1 ships exactly one backend — ``TmuxDriver`` — and routes only Codex
10
+ through it. Claude keeps its tuned direct-PTY path untouched.
11
+
12
+ Design constraints (see ``docs/phase4-implementation-plan.md``):
13
+ * Transport-agnostic above the backend: no tmux assumptions leak into this
14
+ ABC, so a future ``ConptyDriver`` can implement the same interface.
15
+ * ``availability()`` is a *runtime* probe (is the backend usable on this
16
+ box?). Effective drivability = static ``ProviderCaps`` AND availability.
17
+ * Output is a raw byte stream so the existing xterm.js Native transport can
18
+ consume a driver exactly like it consumes the Claude PTY.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import abc
24
+ from collections.abc import Callable
25
+ from enum import Enum
26
+
27
+ from pydantic import BaseModel
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Runtime availability descriptor
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class DriverAvailability(BaseModel):
36
+ """Whether a driver backend is usable on this machine right now.
37
+
38
+ ``available`` gates every driving affordance; ``reason`` is a short,
39
+ user-facing explanation when it is False (e.g. ``"tmux not found on
40
+ PATH"``) so the FE can show *why* live driving is unavailable instead of
41
+ silently hiding it.
42
+ """
43
+
44
+ available: bool
45
+ reason: str | None = None
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # TUI lifecycle state
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ class DriverState(str, Enum):
54
+ """Coarse lifecycle state of the driven TUI, derived from screen scrape.
55
+
56
+ Backend-neutral and intentionally small. The byte stream — not this
57
+ enum — is what the human interacts with; ``capture_state`` exists so the
58
+ *server* can gate submits, report status, and surface blocking prompts.
59
+ """
60
+
61
+ STARTING = "starting" # spawned, nothing recognisable on screen yet
62
+ IDLE = "idle" # composer ready, accepting input
63
+ RUNNING = "running" # a turn is in flight ("esc to interrupt")
64
+ NEEDS_TRUST = "needs_trust" # "trust this directory?" dialog
65
+ NEEDS_APPROVAL = "needs_approval" # command-approval dialog
66
+ NEEDS_LOGIN = "needs_login" # not authenticated
67
+ NEEDS_UPDATE = "needs_update" # blocking "update available" menu
68
+ DEAD = "dead" # backend session no longer exists
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Abstract driver
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class ProviderDriver(abc.ABC):
77
+ """Drive one live provider session over some backend transport.
78
+
79
+ One instance maps to one driven clau-decode session. Concrete backends
80
+ (``TmuxDriver`` today) own the process lifecycle; this ABC fixes the shape
81
+ the server and the Native transport depend on.
82
+ """
83
+
84
+ @classmethod
85
+ @abc.abstractmethod
86
+ def availability(cls) -> DriverAvailability:
87
+ """Probe whether this backend is usable on this machine *now*.
88
+
89
+ Pure and cheap — safe to call on every capability check. Must never
90
+ raise; report unavailability via ``DriverAvailability(available=False,
91
+ reason=...)`` instead.
92
+ """
93
+ ...
94
+
95
+ # -- Lifecycle ------------------------------------------------------------
96
+
97
+ @abc.abstractmethod
98
+ async def spawn(self, *, cols: int, rows: int) -> None:
99
+ """Create the backing session and begin streaming its output.
100
+
101
+ Idempotent guard expected: spawning an already-spawned driver should
102
+ raise rather than silently double-spawn.
103
+ """
104
+ ...
105
+
106
+ @abc.abstractmethod
107
+ async def kill(self) -> None:
108
+ """Tear down the backing session and release all resources.
109
+
110
+ MUST be safe to call more than once (double-kill is a no-op) and safe
111
+ to call on a driver that never spawned.
112
+ """
113
+ ...
114
+
115
+ @abc.abstractmethod
116
+ def is_alive(self) -> bool:
117
+ """Return True if the driven session is still running.
118
+
119
+ Cheap/sync — for authoritative backend checks use the backend's own
120
+ async probe.
121
+ """
122
+ ...
123
+
124
+ # -- Input ----------------------------------------------------------------
125
+
126
+ @abc.abstractmethod
127
+ async def send_text(self, text: str) -> None:
128
+ """Submit a complete prompt as one composer message + Enter.
129
+
130
+ The structured path behind clau-decode's React composer. Handles
131
+ multi-line bodies without premature submission.
132
+ """
133
+ ...
134
+
135
+ @abc.abstractmethod
136
+ async def write_input(self, data: bytes) -> None:
137
+ """Forward raw interactive keystrokes (xterm.js Native view).
138
+
139
+ Used for arbitrary bytes — arrow keys, Esc to dismiss an approval,
140
+ Ctrl-C — that the structured ``send_text`` path can't express.
141
+ """
142
+ ...
143
+
144
+ # -- Output ---------------------------------------------------------------
145
+
146
+ @abc.abstractmethod
147
+ def output_snapshot(self) -> bytes:
148
+ """Return the current bounded output ring (for a reconnecting client)."""
149
+ ...
150
+
151
+ @abc.abstractmethod
152
+ def set_on_chunk(self, cb: Callable[[bytes], None] | None) -> None:
153
+ """Register a sync callback fired with each drained output chunk.
154
+
155
+ The Native transport uses this to fan bytes out over SSE. The callback
156
+ runs inside the reader loop and MUST NOT block or raise.
157
+ """
158
+ ...
159
+
160
+ # -- Sizing & state -------------------------------------------------------
161
+
162
+ @abc.abstractmethod
163
+ async def resize(self, *, cols: int, rows: int) -> None:
164
+ """Resize the driven TUI to ``cols`` x ``rows``."""
165
+ ...
166
+
167
+ @abc.abstractmethod
168
+ async def capture_state(self) -> DriverState:
169
+ """Scrape the current screen and classify it into a ``DriverState``."""
170
+ ...