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,113 @@
1
+ """Driver registry — maps a provider to its live-driving backend.
2
+
3
+ Separate from ``providers/registry.py`` (which maps providers to *decode*
4
+ adapters). This one answers two questions the server needs:
5
+
6
+ * ``availability_for(provider)`` — can we drive this provider on this box
7
+ right now? Composes the backend probe (tmux present) with the provider's
8
+ own CLI binary check (codex present). This is the runtime half of the
9
+ effective-capability gate: ``effective_can_send = caps.can_send AND
10
+ availability_for(provider).available``.
11
+ * ``build_driver(...)`` — construct a ready-to-spawn ``ProviderDriver``.
12
+
13
+ v1 wires exactly one entry: ``codex → TmuxDriver(codex_spawn_builder)``. Claude
14
+ is intentionally absent — it stays on its tuned direct-PTY path. The shape
15
+ supports a future ``claude → TmuxDriver(claude_spawn_builder)`` opt-in with no
16
+ rework.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import shutil
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+
25
+ from .base import DriverAvailability, ProviderDriver
26
+ from .tmux_driver import TmuxDriver, codex_spawn_builder
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class _Backend:
31
+ """How to drive one provider: which driver class, spawn argv, and the
32
+ extra CLI binary that must be present beyond the backend's own."""
33
+
34
+ driver_cls: type[ProviderDriver]
35
+ spawn_builder: Callable[..., list[str]]
36
+ required_bin: str # the provider CLI that must be on PATH
37
+
38
+
39
+ # Provider name → backend wiring. v1: codex only.
40
+ _BACKENDS: dict[str, _Backend] = {
41
+ "codex": _Backend(
42
+ driver_cls=TmuxDriver,
43
+ spawn_builder=codex_spawn_builder,
44
+ required_bin="codex",
45
+ ),
46
+ }
47
+
48
+
49
+ def supports_driving(provider: str) -> bool:
50
+ """True if a driving backend is *wired* for this provider (ignores runtime).
51
+
52
+ Distinct from availability: Claude returns False here (no backend wired),
53
+ while Codex returns True even on a box without tmux — use
54
+ ``availability_for`` for the runtime gate.
55
+ """
56
+ return provider in _BACKENDS
57
+
58
+
59
+ def availability_for(provider: str) -> DriverAvailability:
60
+ """Runtime drivability for ``provider`` = backend usable AND CLI present."""
61
+ backend = _BACKENDS.get(provider)
62
+ if backend is None:
63
+ return DriverAvailability(
64
+ available=False, reason=f"no live-driving backend for {provider!r}"
65
+ )
66
+ backend_avail = backend.driver_cls.availability()
67
+ if not backend_avail.available:
68
+ return backend_avail
69
+ if shutil.which(backend.required_bin) is None:
70
+ return DriverAvailability(
71
+ available=False,
72
+ reason=(
73
+ f"live driving unavailable: {backend.required_bin} not found on PATH"
74
+ ),
75
+ )
76
+ return DriverAvailability(available=True, reason=None)
77
+
78
+
79
+ def build_driver(
80
+ provider: str,
81
+ session_id: str,
82
+ cwd: str,
83
+ *,
84
+ model: str | None = None,
85
+ resume_uuid: str | None = None,
86
+ fresh: bool = False,
87
+ **driver_kwargs,
88
+ ) -> ProviderDriver:
89
+ """Construct a ``ProviderDriver`` for ``provider`` (not yet spawned).
90
+
91
+ For Codex the resume UUID *is* ``Session.id``, so it defaults to
92
+ ``session_id`` — the v1 happy path turns a viewed session continuable with
93
+ no caller bookkeeping.
94
+
95
+ Pass ``fresh=True`` for a brand-new chat: the CLI is spawned with NO resume
96
+ target (codex mints its own rollout UUID on the first message; we adopt it
97
+ afterward). ``fresh`` is explicit because ``resume_uuid=None`` alone is
98
+ ambiguous — it falls back to ``session_id`` for the resume happy path.
99
+ """
100
+ backend = _BACKENDS.get(provider)
101
+ if backend is None:
102
+ raise KeyError(f"no live-driving backend registered for {provider!r}")
103
+ if fresh:
104
+ effective_resume: str | None = None
105
+ elif resume_uuid is not None:
106
+ effective_resume = resume_uuid
107
+ else:
108
+ effective_resume = session_id
109
+ spawn_command = backend.spawn_builder(
110
+ resume_uuid=effective_resume,
111
+ model=model,
112
+ )
113
+ return backend.driver_cls(session_id, cwd, spawn_command, **driver_kwargs)
@@ -0,0 +1,488 @@
1
+ """tmux-backed ``ProviderDriver`` — the v1 POSIX driving backend.
2
+
3
+ A **generic** CLI driver over an isolated tmux server, parameterised by a
4
+ ``spawn_command`` (built per-provider). It is NOT Codex-specific — the same
5
+ class would drive ``claude`` if the optional convergence ever lands.
6
+
7
+ Why tmux (validated in the Phase-4 spike, 2026-06-21 against codex 0.137.0):
8
+ * Persistence / idle-survival for free — the tmux session outlives the app
9
+ process and any disconnect, so the 5-minute reaper can no longer kill a
10
+ long-running task. Reattach = re-``attach`` to the same session.
11
+ * Generic across CLIs — no per-provider TUI state machine to rebuild.
12
+
13
+ Mechanics (each validated in the spike):
14
+ * Isolated server socket ``tmux -L clau-decode`` — never the user's default.
15
+ * One tmux session per driven clau-decode session: ``cd_<sanitised-id>``.
16
+ * Output: a thin ``tmux attach`` client runs inside a Python pty; we stream
17
+ its raw master-fd bytes to the Native transport (same shape as the Claude
18
+ PTY). Multiple clients may attach, so reconnect is just another attach.
19
+ * Input (structured submit): ``load-buffer`` + ``paste-buffer -p`` (bracketed
20
+ paste, so multi-line bodies don't submit early) + a *separate* ``send-keys
21
+ Enter`` after a short settle. The spike proved an immediate text+Enter is
22
+ lossy — the first Enter is eaten — so the settle is load-bearing.
23
+ * Input (raw interactive): write bytes straight to the attached client's pty
24
+ master, which forwards them to the pane (arrows, Esc, Ctrl-C).
25
+ * State: poll ``capture-pane -p`` and match the real Codex TUI markers.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import errno
32
+ import logging
33
+ import os
34
+ import pty
35
+ import re
36
+ import shutil
37
+ from collections.abc import Callable
38
+
39
+ from ..pty_runner import (
40
+ DEFAULT_COLS,
41
+ DEFAULT_ROWS,
42
+ OUTPUT_RING_BYTES,
43
+ _DRAIN_CHUNK,
44
+ _now_ms,
45
+ _set_winsize,
46
+ )
47
+ from .base import DriverAvailability, DriverState, ProviderDriver
48
+
49
+ _log = logging.getLogger(__name__)
50
+
51
+ # Isolated tmux server socket — keeps every driven session off the user's
52
+ # default tmux server so we never list, resize, or kill their windows.
53
+ DEFAULT_SOCKET = "clau-decode"
54
+
55
+ # Seconds to let the TUI register a bracketed paste before the submitting
56
+ # Enter. The spike showed text+Enter sent back-to-back drops the Enter.
57
+ SUBMIT_SETTLE_S = 0.4
58
+
59
+ # tmux session names forbid "." and ":" and must be otherwise shell-safe.
60
+ _SAFE_NAME = re.compile(r"[^A-Za-z0-9_-]")
61
+
62
+ # Device-Attributes *responses* that xterm.js auto-emits when it sees a DA
63
+ # query in the output stream (Primary ``ESC[?...c`` / Secondary ``ESC[>...c``;
64
+ # xterm's DA2 identity is ``ESC[>0;276;0c``). Over a normal terminal the app
65
+ # consumes its own DA reply inline, but through the web bridge the reply
66
+ # round-trips too slowly and lands in the pane as literal input (``0;276;0c``
67
+ # junk in Codex's composer — one per attach/resize). tmux already answers the
68
+ # pane's DA on its behalf, so these client replies are pure cruft: drop them
69
+ # from the raw-input path. A real keystroke never contains a DA response.
70
+ _TERMINAL_REPORT_RE = re.compile(rb"\x1b\[[?>][0-9;]*c")
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Codex TUI markers — captured from the real binary in the Phase-4 spike.
74
+ # Matched against clean ``capture-pane -p`` (no SGR). Order in capture_state
75
+ # matters: blocking dialogs are checked before running/idle.
76
+ # ---------------------------------------------------------------------------
77
+ _MARKER_TRUST = "Do you trust the contents of this directory?"
78
+ _MARKER_APPROVAL = "Would you like to run the following command?"
79
+ _MARKER_UPDATE = "Update available!"
80
+ _MARKER_RUNNING = "esc to interrupt"
81
+ # Best-effort login markers — the spike machine was ChatGPT-authed so these
82
+ # could not be captured live; kept conservative to avoid false positives.
83
+ _MARKERS_LOGIN = ("Sign in with ChatGPT", "Sign in to use Codex", "Not logged in")
84
+
85
+
86
+ def codex_spawn_builder(
87
+ *,
88
+ resume_uuid: str | None = None,
89
+ model: str | None = None,
90
+ sandbox: str | None = None,
91
+ ) -> list[str]:
92
+ """Build the ``codex`` argv for ``TmuxDriver``.
93
+
94
+ The v1 happy path is *resume*: ``codex resume <uuid>`` where ``<uuid>`` is
95
+ exactly the value ``CodexAdapter`` stores as ``Session.id`` (the rollout's
96
+ ``session_meta.payload.id``), so no id translation is needed. Omitting
97
+ ``resume_uuid`` yields a fresh interactive session.
98
+
99
+ ``sandbox`` is left ``None`` by default so codex uses the user's own
100
+ ``config.toml`` policy — driving behaves exactly like the user's normal
101
+ ``codex resume`` from a terminal.
102
+ """
103
+ argv = ["codex"]
104
+ if resume_uuid:
105
+ argv += ["resume", resume_uuid]
106
+ if model:
107
+ argv += ["--model", model]
108
+ if sandbox:
109
+ argv += ["--sandbox", sandbox]
110
+ return argv
111
+
112
+
113
+ class TmuxDriver(ProviderDriver):
114
+ """Drive a CLI inside an isolated tmux server; bridge its pane to bytes."""
115
+
116
+ def __init__(
117
+ self,
118
+ session_id: str,
119
+ cwd: str,
120
+ spawn_command: list[str],
121
+ *,
122
+ socket_name: str = DEFAULT_SOCKET,
123
+ rows: int = DEFAULT_ROWS,
124
+ cols: int = DEFAULT_COLS,
125
+ on_chunk: Callable[[bytes], None] | None = None,
126
+ on_dead: Callable[[], None] | None = None,
127
+ ) -> None:
128
+ self.session_id = session_id
129
+ self._cwd = cwd
130
+ self._spawn_command = list(spawn_command)
131
+ self._socket = socket_name
132
+ self._rows = rows
133
+ self._cols = cols
134
+ self._on_chunk = on_chunk
135
+ self._on_dead = on_dead
136
+
137
+ # tmux session + buffer names, sanitised so the UUID's dashes survive
138
+ # but nothing shell-hostile slips through.
139
+ safe = _SAFE_NAME.sub("_", session_id)
140
+ self._tmux_session = f"cd_{safe}"
141
+ self._buffer = f"cd_{safe}"
142
+
143
+ # Attached-client pty (our output window into the pane).
144
+ self._master_fd = -1
145
+ self._attach_proc: asyncio.subprocess.Process | None = None
146
+ self._reader_registered = False
147
+ self._ring = bytearray()
148
+ self._ring_complete = True
149
+ self._last_output_ms = 0
150
+ self._dead = False
151
+
152
+ # ------------------------------------------------------------------
153
+ # Availability
154
+ # ------------------------------------------------------------------
155
+
156
+ @classmethod
157
+ def availability(cls) -> DriverAvailability:
158
+ """True iff a ``tmux`` binary is on PATH (the only backend requirement).
159
+
160
+ Provider-binary presence (e.g. ``codex``) is checked by the drivers
161
+ registry, which composes it with this probe.
162
+ """
163
+ if shutil.which("tmux") is None:
164
+ return DriverAvailability(
165
+ available=False,
166
+ reason="live driving unavailable: tmux not found on PATH",
167
+ )
168
+ return DriverAvailability(available=True, reason=None)
169
+
170
+ # ------------------------------------------------------------------
171
+ # tmux helpers
172
+ # ------------------------------------------------------------------
173
+
174
+ def _tmux_argv(self, *args: str) -> list[str]:
175
+ return ["tmux", "-L", self._socket, *args]
176
+
177
+ async def _tmux(self, *args: str, stdin: bytes | None = None) -> tuple[int, bytes]:
178
+ """Run a one-shot tmux control command on our isolated server."""
179
+ proc = await asyncio.create_subprocess_exec(
180
+ *self._tmux_argv(*args),
181
+ stdin=asyncio.subprocess.PIPE if stdin is not None else None,
182
+ stdout=asyncio.subprocess.PIPE,
183
+ stderr=asyncio.subprocess.STDOUT,
184
+ )
185
+ out, _ = await proc.communicate(input=stdin)
186
+ return proc.returncode or 0, out
187
+
188
+ async def has_session(self) -> bool:
189
+ """Authoritative async check: does the tmux session still exist?"""
190
+ rc, _ = await self._tmux("has-session", "-t", self._tmux_session)
191
+ return rc == 0
192
+
193
+ async def rename(self, new_session_id: str) -> None:
194
+ """Re-label this driver's tmux session in place (no process restart).
195
+
196
+ Used to adopt the real Codex rollout UUID once codex creates it on the
197
+ first message: the fresh-spawned ``codex`` keeps running and the attach
198
+ client / fd stay live — only the identity (tmux session + buffer name,
199
+ ``session_id``) flips from the placeholder to the real id.
200
+ """
201
+ safe = _SAFE_NAME.sub("_", new_session_id)
202
+ new_tmux = f"cd_{safe}"
203
+ if new_tmux != self._tmux_session:
204
+ await self._tmux("rename-session", "-t", self._tmux_session, new_tmux)
205
+ self._tmux_session = new_tmux
206
+ self._buffer = new_tmux
207
+ self.session_id = new_session_id
208
+
209
+ # ------------------------------------------------------------------
210
+ # Lifecycle
211
+ # ------------------------------------------------------------------
212
+
213
+ async def spawn(self, *, cols: int, rows: int) -> None:
214
+ if self._attach_proc is not None:
215
+ raise RuntimeError(f"driver for {self.session_id} already spawned")
216
+ self._cols, self._rows = cols, rows
217
+
218
+ # Create the session detached at the requested size. ``--`` ends tmux
219
+ # option parsing; the spawn command is passed as already-split argv so
220
+ # tmux execs it directly (no shell, no quoting hazards).
221
+ rc, out = await self._tmux(
222
+ "new-session",
223
+ "-d",
224
+ "-s",
225
+ self._tmux_session,
226
+ "-x",
227
+ str(cols),
228
+ "-y",
229
+ str(rows),
230
+ "-c",
231
+ self._cwd,
232
+ "--",
233
+ *self._spawn_command,
234
+ )
235
+ if rc != 0:
236
+ raise RuntimeError(
237
+ f"tmux new-session failed for {self.session_id}: "
238
+ f"{out.decode('utf-8', 'replace').strip()}"
239
+ )
240
+ # Pin the size so a transient attach client can't shrink the pane.
241
+ await self._tmux(
242
+ "set-option", "-t", self._tmux_session, "window-size", "manual"
243
+ )
244
+ await self.attach()
245
+
246
+ async def attach(self) -> None:
247
+ """Attach an output client inside a pty and start draining its bytes.
248
+
249
+ Used both by ``spawn`` and on reconnect to an already-live session.
250
+ """
251
+ if self._attach_proc is not None and self._attach_proc.returncode is None:
252
+ return # already attached
253
+ if not await self.has_session():
254
+ raise RuntimeError(f"cannot attach: no tmux session for {self.session_id}")
255
+
256
+ # Hide tmux's own status bar — the Native view shows only the driven
257
+ # TUI, never tmux chrome. Set here (not just at spawn) so reconnecting
258
+ # to a session created before this fix also drops the bar. Idempotent.
259
+ await self._tmux("set-option", "-t", self._tmux_session, "status", "off")
260
+
261
+ master_fd, slave_fd = pty.openpty()
262
+ try:
263
+ _set_winsize(slave_fd, self._rows, self._cols)
264
+ os.set_blocking(master_fd, False)
265
+ proc = await asyncio.create_subprocess_exec(
266
+ *self._tmux_argv("attach-session", "-t", self._tmux_session),
267
+ stdin=slave_fd,
268
+ stdout=slave_fd,
269
+ stderr=slave_fd,
270
+ )
271
+ except BaseException:
272
+ for fd in (master_fd, slave_fd):
273
+ try:
274
+ os.close(fd)
275
+ except OSError:
276
+ pass
277
+ raise
278
+ try:
279
+ os.close(slave_fd)
280
+ except OSError:
281
+ pass
282
+
283
+ self._master_fd = master_fd
284
+ self._attach_proc = proc
285
+ self._dead = False
286
+ loop = asyncio.get_running_loop()
287
+ loop.add_reader(master_fd, self._on_readable)
288
+ self._reader_registered = True
289
+
290
+ async def kill(self) -> None:
291
+ """Tear down the tmux session and the attach client. Idempotent."""
292
+ # Kill the session first so the attach client sees EOF and exits.
293
+ try:
294
+ await self._tmux("kill-session", "-t", self._tmux_session)
295
+ except Exception as exc: # pragma: no cover — defensive
296
+ _log.debug("tmux kill-session raised (%s): %s", self.session_id, exc)
297
+ self._detach_reader()
298
+ proc = self._attach_proc
299
+ if proc is not None and proc.returncode is None:
300
+ try:
301
+ proc.terminate()
302
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
303
+ except (ProcessLookupError, asyncio.TimeoutError):
304
+ try:
305
+ proc.kill()
306
+ except ProcessLookupError:
307
+ pass
308
+ self._close_master()
309
+ self._attach_proc = None
310
+ self._dead = True
311
+
312
+ def is_alive(self) -> bool:
313
+ """Cheap sync liveness — is our attach client still running?
314
+
315
+ Reflects *our window* into the session, not the session itself (use
316
+ ``has_session`` for that). False once the client EOFs or we kill it.
317
+ """
318
+ proc = self._attach_proc
319
+ return proc is not None and proc.returncode is None and not self._dead
320
+
321
+ # ------------------------------------------------------------------
322
+ # Input
323
+ # ------------------------------------------------------------------
324
+
325
+ async def send_text(self, text: str) -> None:
326
+ """Submit ``text`` as one composer message (bracketed paste + Enter)."""
327
+ if not await self.has_session():
328
+ raise RuntimeError(f"cannot send: no tmux session for {self.session_id}")
329
+ # Load the body into a named buffer, then bracketed-paste it so a
330
+ # multi-line body lands in the composer without an embedded newline
331
+ # triggering an early submit. ``-d`` deletes the buffer after paste.
332
+ await self._tmux("load-buffer", "-b", self._buffer, "-", stdin=text.encode())
333
+ await self._tmux(
334
+ "paste-buffer", "-p", "-d", "-b", self._buffer, "-t", self._tmux_session
335
+ )
336
+ await asyncio.sleep(SUBMIT_SETTLE_S)
337
+ await self._tmux("send-keys", "-t", self._tmux_session, "Enter")
338
+
339
+ async def write_input(self, data: bytes) -> None:
340
+ """Forward raw bytes to the pane via the attached client's pty.
341
+
342
+ Strips xterm's spurious Device-Attributes replies first (see
343
+ ``_TERMINAL_REPORT_RE``) so they don't corrupt the pane's input line.
344
+ """
345
+ if self._dead or self._master_fd < 0:
346
+ raise RuntimeError(f"driver for {self.session_id} is not attached")
347
+ data = _TERMINAL_REPORT_RE.sub(b"", data)
348
+ if not data:
349
+ return
350
+ try:
351
+ view = memoryview(data)
352
+ while view:
353
+ n = os.write(self._master_fd, view)
354
+ if n <= 0:
355
+ break
356
+ view = view[n:]
357
+ except (BlockingIOError, InterruptedError):
358
+ raise
359
+ except OSError as exc:
360
+ _log.warning("tmux driver: raw write failed (%s): %s", self.session_id, exc)
361
+
362
+ # ------------------------------------------------------------------
363
+ # Output
364
+ # ------------------------------------------------------------------
365
+
366
+ def output_snapshot(self) -> bytes:
367
+ return bytes(self._ring)
368
+
369
+ def output_snapshot_complete(self) -> bool:
370
+ return self._ring_complete
371
+
372
+ def last_output_ms(self) -> int:
373
+ return self._last_output_ms
374
+
375
+ def dimensions(self) -> tuple[int, int]:
376
+ """Return the driven pane's ``(rows, cols)``."""
377
+ return self._rows, self._cols
378
+
379
+ def set_on_chunk(self, cb: Callable[[bytes], None] | None) -> None:
380
+ self._on_chunk = cb
381
+
382
+ def _on_readable(self) -> None:
383
+ """``add_reader`` callback — drain pane bytes into the bounded ring.
384
+
385
+ Mirrors ``PtyChannel._on_readable``: must never raise, treats EOF/EIO
386
+ as terminal, trims the ring to ``OUTPUT_RING_BYTES``.
387
+ """
388
+ fd = self._master_fd
389
+ if fd < 0:
390
+ return
391
+ try:
392
+ chunk = os.read(fd, _DRAIN_CHUNK)
393
+ except (BlockingIOError, InterruptedError):
394
+ return
395
+ except OSError as exc:
396
+ if exc.errno not in (errno.EIO, errno.EBADF):
397
+ _log.warning("tmux driver: read error (%s): %s", self.session_id, exc)
398
+ self._mark_dead_from_drain()
399
+ return
400
+ except Exception as exc: # pragma: no cover — belt and suspenders
401
+ _log.warning("tmux driver: drain error (%s): %s", self.session_id, exc)
402
+ self._mark_dead_from_drain()
403
+ return
404
+
405
+ if not chunk:
406
+ self._mark_dead_from_drain()
407
+ return
408
+
409
+ self._ring.extend(chunk)
410
+ overflow = len(self._ring) - OUTPUT_RING_BYTES
411
+ if overflow > 0:
412
+ del self._ring[:overflow]
413
+ self._ring_complete = False
414
+ self._last_output_ms = _now_ms()
415
+
416
+ if self._on_chunk is not None:
417
+ try:
418
+ self._on_chunk(chunk)
419
+ except Exception as exc: # pragma: no cover — defensive
420
+ _log.warning(
421
+ "tmux driver: on_chunk hook raised (%s): %s", self.session_id, exc
422
+ )
423
+
424
+ def _mark_dead_from_drain(self) -> None:
425
+ self._detach_reader()
426
+ if self._dead:
427
+ return
428
+ self._dead = True
429
+ if self._on_dead is not None:
430
+ try:
431
+ self._on_dead()
432
+ except Exception as exc: # pragma: no cover — defensive
433
+ _log.warning(
434
+ "tmux driver: on_dead hook raised (%s): %s", self.session_id, exc
435
+ )
436
+
437
+ def _detach_reader(self) -> None:
438
+ if self._reader_registered and self._master_fd >= 0:
439
+ try:
440
+ asyncio.get_running_loop().remove_reader(self._master_fd)
441
+ except (RuntimeError, ValueError):
442
+ pass
443
+ self._reader_registered = False
444
+
445
+ def _close_master(self) -> None:
446
+ if self._master_fd >= 0:
447
+ try:
448
+ os.close(self._master_fd)
449
+ except OSError:
450
+ pass
451
+ self._master_fd = -1
452
+
453
+ # ------------------------------------------------------------------
454
+ # Sizing & state
455
+ # ------------------------------------------------------------------
456
+
457
+ async def resize(self, *, cols: int, rows: int) -> None:
458
+ self._cols, self._rows = cols, rows
459
+ if self._master_fd >= 0:
460
+ try:
461
+ _set_winsize(self._master_fd, rows, cols)
462
+ except OSError as exc: # pragma: no cover — defensive
463
+ _log.debug("tmux driver: winsize failed (%s): %s", self.session_id, exc)
464
+ # window-size manual means the pane follows resize-window, not the
465
+ # client, so set it explicitly.
466
+ await self._tmux(
467
+ "resize-window", "-t", self._tmux_session, "-x", str(cols), "-y", str(rows)
468
+ )
469
+
470
+ async def capture_state(self) -> DriverState:
471
+ """Scrape the pane and classify it. Blocking dialogs win over run/idle."""
472
+ rc, out = await self._tmux("capture-pane", "-p", "-t", self._tmux_session)
473
+ if rc != 0:
474
+ return DriverState.DEAD
475
+ text = out.decode("utf-8", "replace")
476
+ if _MARKER_TRUST in text:
477
+ return DriverState.NEEDS_TRUST
478
+ if _MARKER_APPROVAL in text:
479
+ return DriverState.NEEDS_APPROVAL
480
+ if any(m in text for m in _MARKERS_LOGIN):
481
+ return DriverState.NEEDS_LOGIN
482
+ if _MARKER_UPDATE in text:
483
+ return DriverState.NEEDS_UPDATE
484
+ if _MARKER_RUNNING in text:
485
+ return DriverState.RUNNING
486
+ if not text.strip():
487
+ return DriverState.STARTING
488
+ return DriverState.IDLE