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,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
|