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.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- 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
|
+
...
|