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,2406 @@
|
|
|
1
|
+
"""PTY-attached runner — drives the CLI binary in interactive TUI mode.
|
|
2
|
+
|
|
3
|
+
One PTY + one ``claude`` subprocess per focused chat. ``PtyChannel`` owns
|
|
4
|
+
the low-level fd/termios plumbing (master/slave allocation, ``TIOCSCTTY``
|
|
5
|
+
controlling-terminal handoff, ``TIOCSWINSZ`` window sizing, non-blocking
|
|
6
|
+
master-fd drain via ``loop.add_reader``). ``PtyManager`` is the module
|
|
7
|
+
singleton attached to ``app.state.pty_manager`` — it orchestrates
|
|
8
|
+
lazy-spawn-on-focus, dead-PTY auto-respawn on submit, the 5-minute idle
|
|
9
|
+
timer with a 4-minute SSE warn, and clean shutdown.
|
|
10
|
+
|
|
11
|
+
There is no general per-session busy lock: ``submit()`` succeeds while a
|
|
12
|
+
normal turn is in flight so ``/btw`` can flow straight through to PTY stdin.
|
|
13
|
+
The one exception is an active ``/btw`` modal/capture; until that resolves,
|
|
14
|
+
new submits are rejected so foreground slash commands cannot be swallowed by
|
|
15
|
+
the modal. The hidden TUI interprets the bytes; clau-decode's existing JSONL
|
|
16
|
+
watcher → SQLite → SSE pipeline remains the canonical content channel for
|
|
17
|
+
non-ephemeral turns.
|
|
18
|
+
|
|
19
|
+
Phase 2 ships ``/btw`` ephemeral capture: both the user's input and
|
|
20
|
+
claude's PTY-rendered response are persisted to the ``ephemeral_messages``
|
|
21
|
+
table. See ``docs/pty-runner-plan.md`` Phase 2 and
|
|
22
|
+
``docs/pty-runner-phase2-spike.md`` for the full rationale and wire
|
|
23
|
+
protocol. HITL surfaces (auth modal, trust modal, stuck-session inline
|
|
24
|
+
notice) are deferred to later phases.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import errno
|
|
31
|
+
import fcntl
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import pty
|
|
35
|
+
import signal
|
|
36
|
+
import socket
|
|
37
|
+
import struct
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import termios
|
|
41
|
+
import time
|
|
42
|
+
from dataclasses import dataclass, field, replace
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
45
|
+
|
|
46
|
+
from ._auth_env import _subscription_env, spawn_env as _spawn_env # noqa: F401 — re-export
|
|
47
|
+
from .btw_capture import (
|
|
48
|
+
BTW_DISMISS_SEQUENCE,
|
|
49
|
+
extract_btw_response,
|
|
50
|
+
find_response_complete,
|
|
51
|
+
is_btw_input,
|
|
52
|
+
)
|
|
53
|
+
from .locks import LockAlreadyHeld, LockSidecar, _lock_path_for
|
|
54
|
+
from .pty_native import encode_pty_output_chunk, encode_pty_snapshot
|
|
55
|
+
from .pty_screen_state import classify_screen
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from .db import Database
|
|
59
|
+
from .events_bus import EventBroadcaster
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_log = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
DEFAULT_IDLE_TIMEOUT_S = 300.0
|
|
65
|
+
DEFAULT_IDLE_WARN_S = 240.0
|
|
66
|
+
# When the FE signals nav-away via ``/api/pty/blur``, the idle-kill window.
|
|
67
|
+
# Previously 5s, which meant switching between sessions killed the one you
|
|
68
|
+
# navigated away from almost immediately — so you could never keep more than
|
|
69
|
+
# one native session alive at a time, and re-attaching to a switched-away
|
|
70
|
+
# session lost its captured scrollback ring (you'd resume into a fresh,
|
|
71
|
+
# shorter render). Keep blurred sessions alive as long as focused ones so
|
|
72
|
+
# multiple PTYs coexist and their scrollback survives a session switch.
|
|
73
|
+
BLURRED_IDLE_TIMEOUT_S = DEFAULT_IDLE_TIMEOUT_S
|
|
74
|
+
DEFAULT_ROWS = 40
|
|
75
|
+
# Fallback spawn width. The authoritative width is AppConfig.native_pty_cols,
|
|
76
|
+
# pushed onto PtyManager via set_native_cols(); this constant only applies if
|
|
77
|
+
# that was never set. Keep it equal to the AppConfig default.
|
|
78
|
+
DEFAULT_COLS = 100
|
|
79
|
+
|
|
80
|
+
# Bounded ring buffer for PTY output. Two consumers:
|
|
81
|
+
# 1. HITL pattern matching (auth, trust, stuck-session) — only needs recent bytes.
|
|
82
|
+
# 2. Native View hydration on (re)attach — replays this ring into xterm so the
|
|
83
|
+
# browser terminal shows the session's scrollback. This is the binding
|
|
84
|
+
# constraint: the frontend keeps 5000 scrollback rows, so the ring must hold
|
|
85
|
+
# a comparable amount of escape-laden output, or a re-attach can only scroll
|
|
86
|
+
# back through the last ~64KB (≈20% of a long session). Sized to ~5000 rows.
|
|
87
|
+
OUTPUT_RING_BYTES = 4 * 1024 * 1024
|
|
88
|
+
|
|
89
|
+
# Bytes to read per drain callback invocation. ``loop.add_reader`` is
|
|
90
|
+
# level-triggered, so whatever we don't drain now fires the callback again
|
|
91
|
+
# immediately — the only effect of a bigger chunk is FEWER callbacks (and thus
|
|
92
|
+
# fewer SSE messages) for the same burst. Each drained chunk becomes one
|
|
93
|
+
# base64+JSON ``pty_output_chunk`` event (see PtyManager._scan_chunk_for_hitl),
|
|
94
|
+
# so a small chunk fragments a single full-screen repaint into many SSE frames,
|
|
95
|
+
# and that per-frame encode/transport overhead is what makes a mouse-flood
|
|
96
|
+
# scroll choppy over HTTP. 64 KiB coalesces a typical claude repaint into one or
|
|
97
|
+
# two frames while still being small enough that no single read pins the loop.
|
|
98
|
+
_DRAIN_CHUNK = 64 * 1024
|
|
99
|
+
|
|
100
|
+
# Bytes off the TAIL of the ring fed to classify_screen on each chunk. The live
|
|
101
|
+
# screen state we classify is always in the last screenful of output; this caps
|
|
102
|
+
# the per-chunk classification cost at a constant instead of letting it grow
|
|
103
|
+
# with the (up to 4 MB) ring. 64 KiB covers many full repaints of a tall TUI.
|
|
104
|
+
_CLASSIFY_TAIL_BYTES = 64 * 1024
|
|
105
|
+
|
|
106
|
+
# Output coalescing window. claude emits a single TUI repaint as a burst of tiny
|
|
107
|
+
# (~1 KB) PTY writes; without batching, each becomes its own SSE event + classify
|
|
108
|
+
# + frontend xterm.write/repaint. Accumulating reads and flushing once per this
|
|
109
|
+
# window collapses a scroll flick from hundreds of events into a few — roughly
|
|
110
|
+
# one frontend write per frame. ~12 ms ≈ under a 60 fps frame, so the added
|
|
111
|
+
# delivery latency is imperceptible while the batching is large.
|
|
112
|
+
_OUTPUT_COALESCE_S = 0.012
|
|
113
|
+
|
|
114
|
+
# Auth-required pattern: claude prints this banner verbatim when the user
|
|
115
|
+
# is not logged in. We scan each drained chunk for the substring and emit
|
|
116
|
+
# an SSE ``auth_required`` event the first time we see it (per channel).
|
|
117
|
+
# Carryover length is the longest pattern length minus one so the substring
|
|
118
|
+
# is detected even when it straddles two consecutive read() chunks.
|
|
119
|
+
_AUTH_REQUIRED_PATTERN = b"Not logged in"
|
|
120
|
+
_AUTH_REQUIRED_CARRYOVER = len(_AUTH_REQUIRED_PATTERN) - 1
|
|
121
|
+
|
|
122
|
+
# Submission control bytes. Per Phase 0 empirical verification:
|
|
123
|
+
# - ``\r`` (CR, 0x0D) terminates a TUI message — sent at end of submit
|
|
124
|
+
# - ``\x0a`` (LF / Ctrl+J) inserts a newline INSIDE a message — used for
|
|
125
|
+
# content-string ``\n`` translation
|
|
126
|
+
_SUBMIT_BYTE = b"\r"
|
|
127
|
+
_INLINE_NEWLINE = b"\n" # 0x0A — submitted as Ctrl+J, identical wire byte
|
|
128
|
+
|
|
129
|
+
# Graceful-kill timing. SIGINT first (claude's TUI handles SIGINT cleanly),
|
|
130
|
+
# then two Ctrl-Cs in quick succession, then SIGKILL.
|
|
131
|
+
_KILL_SIGINT_WAIT_S = 3.0
|
|
132
|
+
_KILL_CTRLC_WAIT_S = 3.0
|
|
133
|
+
|
|
134
|
+
# Phase 2 — /btw ephemeral capture.
|
|
135
|
+
# If the response-complete marker never arrives (e.g. claude crashes mid-modal
|
|
136
|
+
# or network stall), give up after this many seconds, persist whatever's in the
|
|
137
|
+
# buffer (may be None if extraction yields nothing), and reset the capture state.
|
|
138
|
+
# 180 s mirrors the zai-latency cap used in the spike and the recap path.
|
|
139
|
+
_BTW_STUCK_TIMEOUT_S = 180.0
|
|
140
|
+
|
|
141
|
+
# After the response-complete marker fires, send ESC and wait this long for the
|
|
142
|
+
# TUI to finish its redraw before the channel returns to normal operation.
|
|
143
|
+
_BTW_DISMISS_SETTLE_S = 2.0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PtyOwnershipConflict(RuntimeError):
|
|
147
|
+
"""Raised by ``PtyManager.focus`` when another claude is already
|
|
148
|
+
attached to the session and we'd race it by spawning our own.
|
|
149
|
+
|
|
150
|
+
``foreign_pids`` is the deduped list of OS pids belonging to other
|
|
151
|
+
claudes on the same session id, minus our server pid and our own
|
|
152
|
+
spawned claudes. The HTTP layer turns this into a 409 with the pid
|
|
153
|
+
list in the body so the FE can offer a Take-over banner.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self, foreign_pids: list[int]) -> None:
|
|
157
|
+
super().__init__(
|
|
158
|
+
f"session already attached by foreign claude pid(s): {foreign_pids}"
|
|
159
|
+
)
|
|
160
|
+
self.foreign_pids = list(foreign_pids)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class PtySubmitInFlight(RuntimeError):
|
|
164
|
+
"""Raised when the PTY cannot safely accept another submit yet."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, message: str) -> None:
|
|
167
|
+
super().__init__(message)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Pids of claudes spawned by this process. PtyChannel.start adds to
|
|
171
|
+
# this set; PtyChannel.kill drains it. ``_session_conflict_pids`` uses
|
|
172
|
+
# the set to filter out our own children so the focus-time pre-spawn
|
|
173
|
+
# check doesn't flag them as foreign owners.
|
|
174
|
+
_OWN_CLAUDE_PIDS: set[int] = set()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _lsof_owners(jsonl_path: Optional[Path]) -> list[int]:
|
|
178
|
+
"""PIDs holding ``jsonl_path`` open per ``lsof -t``.
|
|
179
|
+
|
|
180
|
+
Backstop signal — catches the brief append window when a remote
|
|
181
|
+
claude is mid-write. **Blind to idle terminal claudes**, which
|
|
182
|
+
open → write → close per turn (verified 2026-05-26 against
|
|
183
|
+
``zai``; see ``docs/pty-ownership-phase0-findings.md``). The
|
|
184
|
+
pgrep signal in ``_pgrep_session_id`` is the primary detector.
|
|
185
|
+
|
|
186
|
+
macOS + Linux only; fails open (``[]``) on any error.
|
|
187
|
+
"""
|
|
188
|
+
if jsonl_path is None or not jsonl_path.exists():
|
|
189
|
+
return []
|
|
190
|
+
try:
|
|
191
|
+
out = subprocess.run(
|
|
192
|
+
["lsof", "-t", str(jsonl_path)],
|
|
193
|
+
capture_output=True,
|
|
194
|
+
text=True,
|
|
195
|
+
timeout=2.0,
|
|
196
|
+
)
|
|
197
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
198
|
+
_log.debug("pty: _lsof_owners(%s) unavailable: %s", jsonl_path, exc)
|
|
199
|
+
return []
|
|
200
|
+
return [int(x) for x in out.stdout.split() if x.strip().isdigit()]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _pgrep_session_id(session_id: str) -> list[int]:
|
|
204
|
+
"""PIDs whose cmdline carries ``session_id`` as a CLI argument.
|
|
205
|
+
|
|
206
|
+
The primary Phase-0 detector for idle terminal claudes. A live
|
|
207
|
+
``claude --resume <sid>`` (or ``-r <sid>``, ``--resume=<sid>``,
|
|
208
|
+
``--session-id <sid>``) carries the UUID in argv even when the
|
|
209
|
+
JSONL fd is closed between turns — empirically the only signal
|
|
210
|
+
that catches an idle terminal claude (see findings doc).
|
|
211
|
+
|
|
212
|
+
We require the sid to follow a known flag (``--resume``,
|
|
213
|
+
``--session-id``, or ``-r``) separated by ``=`` or a single space.
|
|
214
|
+
This filters out incidental matches in shell history, scripts whose
|
|
215
|
+
text contains the sid in a non-argv context, etc. UUIDs are unique
|
|
216
|
+
enough that even an unqualified substring match has near-zero false
|
|
217
|
+
positives in practice; the flag prefix tightens it further at no
|
|
218
|
+
cost.
|
|
219
|
+
|
|
220
|
+
Known gaps:
|
|
221
|
+
- wrappers / IDE plugins that exec claude with sid embedded in a
|
|
222
|
+
way that doesn't match the flag pattern. Phase 2's lock sidecar
|
|
223
|
+
covers those.
|
|
224
|
+
- the moment between a wrapper script's start and its ``exec`` to
|
|
225
|
+
claude: the wrapper's argv may not include the flag pattern.
|
|
226
|
+
Tolerated — this window is sub-second.
|
|
227
|
+
"""
|
|
228
|
+
pattern = rf"(--resume|--session-id|-r)[= ]{session_id}"
|
|
229
|
+
try:
|
|
230
|
+
out = subprocess.run(
|
|
231
|
+
["pgrep", "-f", pattern],
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
timeout=2.0,
|
|
235
|
+
)
|
|
236
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
237
|
+
_log.debug("pty: _pgrep_session_id(%s) unavailable: %s", session_id, exc)
|
|
238
|
+
return []
|
|
239
|
+
# pgrep exits 1 with no stdout when there are no matches — not an error.
|
|
240
|
+
if out.returncode not in (0, 1):
|
|
241
|
+
return []
|
|
242
|
+
return [int(x) for x in out.stdout.split() if x.strip().isdigit()]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _fresh_foreign_sidecar(jsonl_path: Optional[Path]) -> Optional[LockSidecar]:
|
|
246
|
+
"""Return the lock sidecar IF it exists, is fresh, and is not us.
|
|
247
|
+
|
|
248
|
+
Phase-1 authoritative signal. Used by both
|
|
249
|
+
``_session_conflict_pids`` (to add the lock pid even when pgrep
|
|
250
|
+
misses the cmdline pattern) and by ``PtyManager.ownership`` (to
|
|
251
|
+
surface structured owner metadata to the FE).
|
|
252
|
+
"""
|
|
253
|
+
if jsonl_path is None:
|
|
254
|
+
return None
|
|
255
|
+
sc = LockSidecar.read(jsonl_path)
|
|
256
|
+
if sc is None:
|
|
257
|
+
return None
|
|
258
|
+
if sc.is_self() or sc.is_stale():
|
|
259
|
+
return None
|
|
260
|
+
return sc
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _unlink_fresh_foreign_sidecar(jsonl_path: Optional[Path]) -> bool:
|
|
264
|
+
"""Remove a fresh foreign sidecar after explicit user takeover.
|
|
265
|
+
|
|
266
|
+
Normal acquisition still treats a fresh cross-host sidecar as
|
|
267
|
+
authoritative. This helper is only for ``/api/pty/takeover`` after the
|
|
268
|
+
user has chosen to clear stale ownership metadata.
|
|
269
|
+
"""
|
|
270
|
+
sc = _fresh_foreign_sidecar(jsonl_path)
|
|
271
|
+
if sc is None:
|
|
272
|
+
return False
|
|
273
|
+
lp = _lock_path_for(sc.jsonl_path)
|
|
274
|
+
try:
|
|
275
|
+
os.unlink(str(lp))
|
|
276
|
+
except FileNotFoundError:
|
|
277
|
+
return False
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
_log.warning("pty: takeover could not unlink sidecar %s: %s", lp, exc)
|
|
280
|
+
return False
|
|
281
|
+
_log.info(
|
|
282
|
+
"pty: takeover unlinked sidecar for %s held by %s@%s pid %d",
|
|
283
|
+
sc.jsonl_path,
|
|
284
|
+
sc.owner_kind,
|
|
285
|
+
sc.hostname,
|
|
286
|
+
sc.pid,
|
|
287
|
+
)
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _session_conflict_pids(
|
|
292
|
+
session_id: str,
|
|
293
|
+
jsonl_path: Optional[Path],
|
|
294
|
+
) -> list[int]:
|
|
295
|
+
"""Union of pgrep + lsof + lock-sidecar signals; foreign claudes
|
|
296
|
+
attached to ``session_id``.
|
|
297
|
+
|
|
298
|
+
Returns deduped, sorted PIDs that look like a competing claude
|
|
299
|
+
process for this session, minus:
|
|
300
|
+
- our server pid (``os.getpid()``).
|
|
301
|
+
- any claude we've spawned ourselves (``_OWN_CLAUDE_PIDS``).
|
|
302
|
+
|
|
303
|
+
Three signals are unioned, each with known blind spots covered by
|
|
304
|
+
the others:
|
|
305
|
+
- **pgrep** (primary): catches terminal claudes whose cmdline
|
|
306
|
+
contains ``<session_id>``. Misses wrappers that mangle argv.
|
|
307
|
+
- **lsof** (backstop): catches the brief append moment when a
|
|
308
|
+
wrapper-hidden claude actually writes the JSONL. Blind to idle.
|
|
309
|
+
- **lock sidecar** (authoritative for cooperating writers): the
|
|
310
|
+
Phase-1 ``.lock`` file written by clau-decode and — once
|
|
311
|
+
Phase 2 ships — by ``claude-wrapper``-spawned terminals.
|
|
312
|
+
Cross-host sidecars contribute no pid (we can't signal a
|
|
313
|
+
remote pid); their existence is surfaced via
|
|
314
|
+
``PtyManager.ownership.foreign_owner`` instead.
|
|
315
|
+
"""
|
|
316
|
+
own_pid = os.getpid()
|
|
317
|
+
pids: set[int] = set(_pgrep_session_id(session_id))
|
|
318
|
+
pids.update(_lsof_owners(jsonl_path))
|
|
319
|
+
sc = _fresh_foreign_sidecar(jsonl_path)
|
|
320
|
+
if sc is not None and sc.hostname == socket.gethostname():
|
|
321
|
+
pids.add(sc.pid)
|
|
322
|
+
pids.discard(own_pid)
|
|
323
|
+
pids.difference_update(_OWN_CLAUDE_PIDS)
|
|
324
|
+
return sorted(pids)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def _pty_env(rows: int, cols: int, bin_name: str) -> dict[str, str]:
|
|
328
|
+
"""Build the spawn env. Auth-aware stripping lives in ``_auth_env``;
|
|
329
|
+
here we just layer the PTY-specific overrides on top.
|
|
330
|
+
"""
|
|
331
|
+
env = await _spawn_env(bin_name)
|
|
332
|
+
env.pop("NO_COLOR", None)
|
|
333
|
+
env["TERM"] = "xterm-256color"
|
|
334
|
+
env["COLORTERM"] = "truecolor"
|
|
335
|
+
env["FORCE_COLOR"] = "1"
|
|
336
|
+
env["CLICOLOR_FORCE"] = "1"
|
|
337
|
+
env["COLUMNS"] = str(cols)
|
|
338
|
+
env["LINES"] = str(rows)
|
|
339
|
+
return env
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _now_ms() -> int:
|
|
343
|
+
return int(time.time() * 1000)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
|
347
|
+
"""Apply window size to ``fd`` via ``TIOCSWINSZ``.
|
|
348
|
+
|
|
349
|
+
Must be called on the SLAVE fd before spawning, so the child inherits
|
|
350
|
+
a correctly-sized terminal — claude's TUI inspects ``ws_row``/``ws_col``
|
|
351
|
+
at startup and lays out its frame against those numbers.
|
|
352
|
+
"""
|
|
353
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# The controlling-TTY claim (setsid + TIOCSCTTY) happens in the spawned
|
|
357
|
+
# wrapper process at ``clau_decode/_pty_preexec.py`` — we cannot use
|
|
358
|
+
# ``preexec_fn`` because uvloop's subprocess implementation refuses to run
|
|
359
|
+
# it. See that module's docstring for the full rationale.
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# PtyChannel
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass
|
|
368
|
+
class _ChannelState:
|
|
369
|
+
"""Per-channel mutable state. Separate from PtyChannel to keep the
|
|
370
|
+
public-API surface free of low-level field clutter."""
|
|
371
|
+
|
|
372
|
+
proc: Optional[asyncio.subprocess.Process] = None
|
|
373
|
+
master_fd: int = -1
|
|
374
|
+
slave_fd: int = -1
|
|
375
|
+
reader_registered: bool = False
|
|
376
|
+
ring: bytearray = field(default_factory=bytearray)
|
|
377
|
+
ring_complete: bool = True
|
|
378
|
+
last_input_ms: int = 0
|
|
379
|
+
last_pty_output_ms: int = 0
|
|
380
|
+
last_pty_output_seq: int = 0
|
|
381
|
+
dead: bool = False
|
|
382
|
+
# Per-channel guard for the ``auth_required`` SSE: claude prints
|
|
383
|
+
# ``Not logged in`` once at startup and again on every redraw of the
|
|
384
|
+
# error frame. We emit exactly once per channel lifetime; a respawn
|
|
385
|
+
# gets a fresh channel state and may re-emit.
|
|
386
|
+
auth_required_emitted: bool = False
|
|
387
|
+
|
|
388
|
+
# Phase 2 — /btw ephemeral capture state.
|
|
389
|
+
# ``expecting_btw_response`` is set by PtyManager.submit() when a /btw
|
|
390
|
+
# input is detected. While True every drained chunk is appended to
|
|
391
|
+
# ``btw_buffer`` (separate from the bounded ring — must be unbounded to
|
|
392
|
+
# hold a full /btw response). When ``BTW_RESPONSE_COMPLETE_MARKER``
|
|
393
|
+
# appears in the buffer, PtyChannel.finalize_btw_capture() is scheduled
|
|
394
|
+
# as a fire-and-forget task. The state is reset after finalization.
|
|
395
|
+
expecting_btw_response: bool = False
|
|
396
|
+
btw_buffer: bytearray = field(default_factory=bytearray)
|
|
397
|
+
btw_input_row_id: Optional[int] = None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class PtyChannel:
|
|
401
|
+
"""One PTY + one claude subprocess. Created on demand by PtyManager."""
|
|
402
|
+
|
|
403
|
+
def __init__(
|
|
404
|
+
self,
|
|
405
|
+
session_id: str,
|
|
406
|
+
argv: list[str],
|
|
407
|
+
cwd: str,
|
|
408
|
+
env: dict[str, str],
|
|
409
|
+
rows: int = DEFAULT_ROWS,
|
|
410
|
+
cols: int = DEFAULT_COLS,
|
|
411
|
+
on_chunk: Optional[Callable[["PtyChannel", bytes], None]] = None,
|
|
412
|
+
on_dead: Optional[Callable[["PtyChannel"], None]] = None,
|
|
413
|
+
jsonl_path: Optional[Path] = None,
|
|
414
|
+
ui_endpoint: Optional[str] = None,
|
|
415
|
+
db: Optional["Database"] = None,
|
|
416
|
+
bus: Optional["EventBroadcaster"] = None,
|
|
417
|
+
) -> None:
|
|
418
|
+
self.session_id = session_id
|
|
419
|
+
self._argv = list(argv)
|
|
420
|
+
self._cwd = cwd
|
|
421
|
+
self._env = dict(env)
|
|
422
|
+
self._rows = rows
|
|
423
|
+
self._cols = cols
|
|
424
|
+
self._state = _ChannelState()
|
|
425
|
+
# Sync callback invoked after each drained chunk is appended to
|
|
426
|
+
# the ring buffer. PtyManager uses this to pattern-match the TUI's
|
|
427
|
+
# output for HITL signals ("Not logged in" → auth_required SSE).
|
|
428
|
+
# Sync (not async) so it runs inside the drain callback without
|
|
429
|
+
# scheduling a task per chunk; implementations MUST not block.
|
|
430
|
+
self._on_chunk = on_chunk
|
|
431
|
+
self._on_dead = on_dead
|
|
432
|
+
# ``last_activity_ms`` is the max of input + output timestamps and
|
|
433
|
+
# is computed on read; the underlying fields live in ``_state``.
|
|
434
|
+
|
|
435
|
+
# Phase 1 — lock sidecar (pty-ownership-plan.md). Acquired in
|
|
436
|
+
# start() and released in kill(). ``jsonl_path=None`` skips the
|
|
437
|
+
# sidecar entirely (e.g. brand-new sessions whose JSONL doesn't
|
|
438
|
+
# exist yet — though the lock file's create is fine even then,
|
|
439
|
+
# we just don't track ui_endpoint for those callers).
|
|
440
|
+
self._jsonl_path = jsonl_path
|
|
441
|
+
self._ui_endpoint = ui_endpoint
|
|
442
|
+
self._lock_sidecar: Optional["LockSidecar"] = None
|
|
443
|
+
|
|
444
|
+
# Phase 2 — /btw ephemeral capture. ``db`` is optional so channels
|
|
445
|
+
# created without it (e.g. bare PtyChannel unit tests, recap runner)
|
|
446
|
+
# still work — the btw marker check is a no-op when db is None.
|
|
447
|
+
self._db = db
|
|
448
|
+
# ``bus`` is also optional; when set, finalize/timeout publish an
|
|
449
|
+
# ``ephemeral_pair_persisted`` SSE event so the FE can refresh the
|
|
450
|
+
# ChatView inline. Recap channel + bare unit tests pass None.
|
|
451
|
+
self._bus = bus
|
|
452
|
+
|
|
453
|
+
# Stuck-modal timeout in seconds. Stored as an instance attribute so
|
|
454
|
+
# tests can override it per-channel (e.g. ``channel._btw_stuck_timeout_s
|
|
455
|
+
# = 1.0``) without patching the module-level constant.
|
|
456
|
+
self._btw_stuck_timeout_s: float = _BTW_STUCK_TIMEOUT_S
|
|
457
|
+
|
|
458
|
+
# Fire-and-forget task slots: one for the finalize coroutine (marker
|
|
459
|
+
# triggered) and one for the stuck-modal timeout (180 s backstop).
|
|
460
|
+
# Only one of each is live at a time per channel.
|
|
461
|
+
self._btw_finalize_task: Optional[asyncio.Task] = None
|
|
462
|
+
self._btw_timeout_task: Optional[asyncio.Task] = None
|
|
463
|
+
|
|
464
|
+
def _publish_submit_completed(
|
|
465
|
+
self,
|
|
466
|
+
*,
|
|
467
|
+
kind: str,
|
|
468
|
+
status: str,
|
|
469
|
+
input_row_id: Optional[int],
|
|
470
|
+
response_row_id: Optional[int],
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Publish a terminal submit lifecycle event for UI optimistic state."""
|
|
473
|
+
if self._bus is None:
|
|
474
|
+
return
|
|
475
|
+
try:
|
|
476
|
+
self._bus.publish(
|
|
477
|
+
{
|
|
478
|
+
"type": "pty_submit_completed",
|
|
479
|
+
"session_id": self.session_id,
|
|
480
|
+
"kind": kind,
|
|
481
|
+
"status": status,
|
|
482
|
+
"input_id": input_row_id,
|
|
483
|
+
"response_id": response_row_id,
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
except Exception as exc:
|
|
487
|
+
_log.warning(
|
|
488
|
+
"pty: submit completion publish raised (session %s): %s",
|
|
489
|
+
self.session_id,
|
|
490
|
+
exc,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# ------------------------------------------------------------------
|
|
494
|
+
# Lifecycle
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
async def start(self) -> None:
|
|
498
|
+
"""Allocate a PTY pair, spawn ``claude``, register the output drain."""
|
|
499
|
+
if self._state.proc is not None:
|
|
500
|
+
raise RuntimeError(f"channel for {self.session_id} already started")
|
|
501
|
+
|
|
502
|
+
# Phase 1 — acquire the lock sidecar BEFORE spawning. Raising
|
|
503
|
+
# here means we never start a subprocess that would race a
|
|
504
|
+
# foreign owner. PtyManager.focus surfaces ``LockAlreadyHeld``
|
|
505
|
+
# as a ``PtyOwnershipConflict`` 409 with the foreign pid.
|
|
506
|
+
if self._jsonl_path is not None:
|
|
507
|
+
try:
|
|
508
|
+
self._lock_sidecar = LockSidecar.acquire(
|
|
509
|
+
self._jsonl_path,
|
|
510
|
+
owner_kind="clau-decode",
|
|
511
|
+
ui_endpoint=self._ui_endpoint,
|
|
512
|
+
)
|
|
513
|
+
self._lock_sidecar.start_heartbeat()
|
|
514
|
+
except LockAlreadyHeld:
|
|
515
|
+
raise
|
|
516
|
+
|
|
517
|
+
master_fd, slave_fd = pty.openpty()
|
|
518
|
+
try:
|
|
519
|
+
_set_winsize(slave_fd, self._rows, self._cols)
|
|
520
|
+
os.set_blocking(master_fd, False)
|
|
521
|
+
|
|
522
|
+
_log.info(
|
|
523
|
+
"pty: spawning %s (session %s, cwd=%s, rows=%d, cols=%d)",
|
|
524
|
+
" ".join(self._argv),
|
|
525
|
+
self.session_id,
|
|
526
|
+
self._cwd,
|
|
527
|
+
self._rows,
|
|
528
|
+
self._cols,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# We spawn through a tiny wrapper module that performs
|
|
532
|
+
# ``os.setsid()`` + ``TIOCSCTTY`` in the child before exec'ing the
|
|
533
|
+
# target binary. This works under both the default asyncio loop
|
|
534
|
+
# and uvloop (uvloop deliberately does not run ``preexec_fn``).
|
|
535
|
+
wrapper_argv = [
|
|
536
|
+
sys.executable,
|
|
537
|
+
"-m",
|
|
538
|
+
"clau_decode._pty_preexec",
|
|
539
|
+
*self._argv,
|
|
540
|
+
]
|
|
541
|
+
proc = await asyncio.create_subprocess_exec(
|
|
542
|
+
*wrapper_argv,
|
|
543
|
+
cwd=self._cwd,
|
|
544
|
+
env=self._env,
|
|
545
|
+
stdin=slave_fd,
|
|
546
|
+
stdout=slave_fd,
|
|
547
|
+
stderr=slave_fd,
|
|
548
|
+
)
|
|
549
|
+
except BaseException:
|
|
550
|
+
# Spawn failed — release both fds so we don't leak.
|
|
551
|
+
try:
|
|
552
|
+
os.close(master_fd)
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
try:
|
|
556
|
+
os.close(slave_fd)
|
|
557
|
+
except OSError:
|
|
558
|
+
pass
|
|
559
|
+
# Also drop the lock sidecar so a follow-up retry isn't
|
|
560
|
+
# blocked by our orphaned lock.
|
|
561
|
+
if self._lock_sidecar is not None:
|
|
562
|
+
try:
|
|
563
|
+
self._lock_sidecar.release()
|
|
564
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
565
|
+
_log.debug(
|
|
566
|
+
"pty: lock release after spawn failure raised (session %s): %s",
|
|
567
|
+
self.session_id,
|
|
568
|
+
exc,
|
|
569
|
+
)
|
|
570
|
+
self._lock_sidecar = None
|
|
571
|
+
raise
|
|
572
|
+
|
|
573
|
+
# Parent doesn't need the slave fd once the child has inherited it.
|
|
574
|
+
# Keeping it open would prevent EOF on master when the child exits.
|
|
575
|
+
try:
|
|
576
|
+
os.close(slave_fd)
|
|
577
|
+
except OSError as exc:
|
|
578
|
+
_log.warning(
|
|
579
|
+
"pty: failed to close slave fd (session %s): %s",
|
|
580
|
+
self.session_id,
|
|
581
|
+
exc,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
self._state.proc = proc
|
|
585
|
+
self._state.master_fd = master_fd
|
|
586
|
+
self._state.slave_fd = -1 # closed; resize() reapplies via master fd
|
|
587
|
+
|
|
588
|
+
# Register the spawned pid so the Phase-0 ownership check doesn't
|
|
589
|
+
# treat our own claudes as foreign owners. Track the wrapper-proc
|
|
590
|
+
# pid: ``_pty_preexec`` does ``execvp`` to claude, which retains
|
|
591
|
+
# the pid, so this single registration covers both program states.
|
|
592
|
+
_OWN_CLAUDE_PIDS.add(proc.pid)
|
|
593
|
+
|
|
594
|
+
loop = asyncio.get_running_loop()
|
|
595
|
+
loop.add_reader(master_fd, self._on_readable)
|
|
596
|
+
self._state.reader_registered = True
|
|
597
|
+
|
|
598
|
+
async def await_ready(self, timeout_s: float = 3.0, settle_s: float = 0.15) -> bool:
|
|
599
|
+
"""Block until the TUI signals it's ready to accept input.
|
|
600
|
+
|
|
601
|
+
Phase 0 verified that ``claude`` v2.1.143 emits the bracketed-paste
|
|
602
|
+
enable escape ``\\x1b[?2004h`` once its TUI is mounted and ready to
|
|
603
|
+
consume stdin. We poll the bounded output ring for that sequence
|
|
604
|
+
with a backstop timeout — without this, model-switch respawns lose
|
|
605
|
+
the first write because ``channel.start()`` returns when the
|
|
606
|
+
subprocess is launched but well before the TUI's input handler is
|
|
607
|
+
attached.
|
|
608
|
+
|
|
609
|
+
The marker is emitted just before the TUI finishes installing raw
|
|
610
|
+
input handling. Settle briefly after seeing it so a cold first
|
|
611
|
+
submit's trailing CR is interpreted by the TUI, not by the terminal
|
|
612
|
+
line discipline as a newline. Returns True if the marker was seen,
|
|
613
|
+
False on timeout (caller can still write; the write may just race
|
|
614
|
+
the bootstrap).
|
|
615
|
+
"""
|
|
616
|
+
marker = b"\x1b[?2004h"
|
|
617
|
+
loop = asyncio.get_running_loop()
|
|
618
|
+
end = loop.time() + timeout_s
|
|
619
|
+
while loop.time() < end:
|
|
620
|
+
if self._state.dead:
|
|
621
|
+
return False
|
|
622
|
+
if marker in self._state.ring:
|
|
623
|
+
if settle_s > 0:
|
|
624
|
+
await asyncio.sleep(settle_s)
|
|
625
|
+
return not self._state.dead
|
|
626
|
+
await asyncio.sleep(0.05)
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
# ------------------------------------------------------------------
|
|
630
|
+
# I/O
|
|
631
|
+
# ------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
def write(self, data: bytes) -> None:
|
|
634
|
+
"""Non-blocking write to master_fd. No busy lock."""
|
|
635
|
+
if self._state.dead or self._state.master_fd < 0:
|
|
636
|
+
raise RuntimeError(f"channel for {self.session_id} is not alive")
|
|
637
|
+
if not data:
|
|
638
|
+
return
|
|
639
|
+
try:
|
|
640
|
+
# PTY master is non-blocking; a partial write here would be
|
|
641
|
+
# unusual for the message-sized payloads we send, but handle
|
|
642
|
+
# it defensively by retrying the tail synchronously since the
|
|
643
|
+
# whole operation is best-effort (no per-session busy lock).
|
|
644
|
+
view = memoryview(data)
|
|
645
|
+
while view:
|
|
646
|
+
n = os.write(self._state.master_fd, view)
|
|
647
|
+
if n <= 0:
|
|
648
|
+
break
|
|
649
|
+
view = view[n:]
|
|
650
|
+
except (BlockingIOError, InterruptedError):
|
|
651
|
+
# Extremely unlikely for a message-sized write; surface so
|
|
652
|
+
# the caller can decide whether to retry.
|
|
653
|
+
raise
|
|
654
|
+
except OSError as exc:
|
|
655
|
+
_log.warning(
|
|
656
|
+
"pty: write failed (session %s): %s",
|
|
657
|
+
self.session_id,
|
|
658
|
+
exc,
|
|
659
|
+
)
|
|
660
|
+
self._state.dead = True
|
|
661
|
+
raise
|
|
662
|
+
self._state.last_input_ms = _now_ms()
|
|
663
|
+
|
|
664
|
+
def _on_readable(self) -> None:
|
|
665
|
+
"""``loop.add_reader`` callback — drain available bytes into the ring.
|
|
666
|
+
|
|
667
|
+
Must never raise: an exception here would propagate to the event
|
|
668
|
+
loop's default handler and could destabilize the whole server.
|
|
669
|
+
"""
|
|
670
|
+
fd = self._state.master_fd
|
|
671
|
+
if fd < 0:
|
|
672
|
+
return
|
|
673
|
+
try:
|
|
674
|
+
chunk = os.read(fd, _DRAIN_CHUNK)
|
|
675
|
+
except (BlockingIOError, InterruptedError):
|
|
676
|
+
return
|
|
677
|
+
except OSError as exc:
|
|
678
|
+
# EIO is normal when the slave side closes (child exited);
|
|
679
|
+
# treat any read error as terminal for this channel.
|
|
680
|
+
if exc.errno not in (errno.EIO, errno.EBADF):
|
|
681
|
+
_log.warning(
|
|
682
|
+
"pty: read error (session %s): %s",
|
|
683
|
+
self.session_id,
|
|
684
|
+
exc,
|
|
685
|
+
)
|
|
686
|
+
self._mark_dead_from_drain()
|
|
687
|
+
return
|
|
688
|
+
except Exception as exc: # pragma: no cover — belt and suspenders
|
|
689
|
+
_log.warning(
|
|
690
|
+
"pty: unexpected drain error (session %s): %s",
|
|
691
|
+
self.session_id,
|
|
692
|
+
exc,
|
|
693
|
+
)
|
|
694
|
+
self._mark_dead_from_drain()
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
if not chunk:
|
|
698
|
+
# EOF on master — child has fully closed the slave side.
|
|
699
|
+
self._mark_dead_from_drain()
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Append to bounded ring. bytearray + slice trim is allocation-light
|
|
703
|
+
# for the small chunks we expect and gives us a single contiguous
|
|
704
|
+
# buffer that later phases can pattern-match against directly.
|
|
705
|
+
ring = self._state.ring
|
|
706
|
+
ring.extend(chunk)
|
|
707
|
+
overflow = len(ring) - OUTPUT_RING_BYTES
|
|
708
|
+
if overflow > 0:
|
|
709
|
+
del ring[:overflow]
|
|
710
|
+
self._state.ring_complete = False
|
|
711
|
+
self._state.last_pty_output_ms = _now_ms()
|
|
712
|
+
self._state.last_pty_output_seq += 1
|
|
713
|
+
|
|
714
|
+
# Fire the per-chunk hook AFTER the ring write so the callback
|
|
715
|
+
# sees a consistent view. The hook is best-effort: any exception
|
|
716
|
+
# is logged and swallowed because this runs inside the asyncio
|
|
717
|
+
# reader callback (an unhandled exception would propagate to the
|
|
718
|
+
# default loop exception handler and could destabilize the server).
|
|
719
|
+
if self._on_chunk is not None:
|
|
720
|
+
try:
|
|
721
|
+
self._on_chunk(self, chunk)
|
|
722
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
723
|
+
_log.warning(
|
|
724
|
+
"pty: on_chunk hook raised (session %s): %s",
|
|
725
|
+
self.session_id,
|
|
726
|
+
exc,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# Phase 2 — /btw ephemeral output capture. While expecting a btw
|
|
730
|
+
# response, accumulate every chunk into the growable btw_buffer
|
|
731
|
+
# (separate from the bounded ring so it can hold a full response).
|
|
732
|
+
# Check for the response-complete marker after each append; if
|
|
733
|
+
# found, schedule finalize_btw_capture() as a fire-and-forget task.
|
|
734
|
+
# This runs synchronously inside the add_reader callback — the marker
|
|
735
|
+
# check (bytes.find, C-level) is cheap on a sub-100 KB buffer.
|
|
736
|
+
if self._state.expecting_btw_response and self._db is not None:
|
|
737
|
+
self._state.btw_buffer.extend(chunk)
|
|
738
|
+
if find_response_complete(self._state.btw_buffer) >= 0:
|
|
739
|
+
# Schedule finalize — idempotent: skip if already pending.
|
|
740
|
+
if self._btw_finalize_task is None or self._btw_finalize_task.done():
|
|
741
|
+
try:
|
|
742
|
+
loop = asyncio.get_running_loop()
|
|
743
|
+
self._btw_finalize_task = loop.create_task(
|
|
744
|
+
self._finalize_btw_capture()
|
|
745
|
+
)
|
|
746
|
+
_log.info(
|
|
747
|
+
"pty: btw response-complete marker detected — "
|
|
748
|
+
"finalize scheduled (session %s, buffer=%d bytes)",
|
|
749
|
+
self.session_id,
|
|
750
|
+
len(self._state.btw_buffer),
|
|
751
|
+
)
|
|
752
|
+
except RuntimeError: # pragma: no cover — loop closed
|
|
753
|
+
pass
|
|
754
|
+
|
|
755
|
+
def _mark_dead_from_drain(self) -> None:
|
|
756
|
+
"""Remove the loop reader and mark the channel dead.
|
|
757
|
+
|
|
758
|
+
Called from inside the reader callback when EOF or a fatal read
|
|
759
|
+
error indicates the subprocess has gone away. ``kill()`` handles
|
|
760
|
+
the formal teardown — this is just the drain-side bookkeeping.
|
|
761
|
+
"""
|
|
762
|
+
if self._state.reader_registered and self._state.master_fd >= 0:
|
|
763
|
+
try:
|
|
764
|
+
loop = asyncio.get_running_loop()
|
|
765
|
+
loop.remove_reader(self._state.master_fd)
|
|
766
|
+
except (RuntimeError, ValueError):
|
|
767
|
+
pass
|
|
768
|
+
self._state.reader_registered = False
|
|
769
|
+
if self._state.dead:
|
|
770
|
+
return
|
|
771
|
+
self._state.dead = True
|
|
772
|
+
if self._on_dead is not None:
|
|
773
|
+
try:
|
|
774
|
+
self._on_dead(self)
|
|
775
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
776
|
+
_log.warning(
|
|
777
|
+
"pty: on_dead hook raised (session %s): %s",
|
|
778
|
+
self.session_id,
|
|
779
|
+
exc,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
# ------------------------------------------------------------------
|
|
783
|
+
# Phase 2 — /btw ephemeral capture
|
|
784
|
+
# ------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
async def _finalize_btw_capture(self) -> None:
|
|
787
|
+
"""Run after BTW_RESPONSE_COMPLETE_MARKER hits the btw_buffer.
|
|
788
|
+
|
|
789
|
+
Sequence:
|
|
790
|
+
1. Send BTW_DISMISS_SEQUENCE (ESC) to the PTY — also resets the
|
|
791
|
+
idle timer since it's an input event.
|
|
792
|
+
2. await asyncio.sleep(_BTW_DISMISS_SETTLE_S) # TUI redraw window
|
|
793
|
+
3. extract_btw_response(bytes(btw_buffer))
|
|
794
|
+
4. If extracted text is non-None and btw_input_row_id is not None:
|
|
795
|
+
await db.record_ephemeral_response(btw_input_row_id, extracted)
|
|
796
|
+
5. Reset state: expecting_btw_response=False, btw_buffer.clear(),
|
|
797
|
+
btw_input_row_id=None. Cancel the stuck-modal timeout task.
|
|
798
|
+
6. Return (input_row_id, response_row_id) — both may be None on
|
|
799
|
+
partial failure.
|
|
800
|
+
|
|
801
|
+
Errors are logged and swallowed (fire-and-forget task).
|
|
802
|
+
"""
|
|
803
|
+
input_row_id = self._state.btw_input_row_id
|
|
804
|
+
response_row_id: Optional[int] = None
|
|
805
|
+
status: Optional[str] = None
|
|
806
|
+
try:
|
|
807
|
+
# Cancel the stuck-modal timeout — we got the marker.
|
|
808
|
+
if self._btw_timeout_task is not None and not self._btw_timeout_task.done():
|
|
809
|
+
self._btw_timeout_task.cancel()
|
|
810
|
+
self._btw_timeout_task = None
|
|
811
|
+
|
|
812
|
+
# 1. Dismiss the modal with ESC. Also resets last_input_ms,
|
|
813
|
+
# which the idle-timer watchdog uses — an ESC is an input.
|
|
814
|
+
try:
|
|
815
|
+
self.write(BTW_DISMISS_SEQUENCE)
|
|
816
|
+
except Exception as exc:
|
|
817
|
+
_log.warning(
|
|
818
|
+
"pty: btw finalize — ESC write failed (session %s): %s",
|
|
819
|
+
self.session_id,
|
|
820
|
+
exc,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# 2. Allow the TUI to complete its modal tear-down.
|
|
824
|
+
await asyncio.sleep(_BTW_DISMISS_SETTLE_S)
|
|
825
|
+
|
|
826
|
+
# 3. Extract the response text.
|
|
827
|
+
raw = bytes(self._state.btw_buffer)
|
|
828
|
+
extracted = extract_btw_response(raw)
|
|
829
|
+
|
|
830
|
+
# 4. Persist the response row if we have both sides.
|
|
831
|
+
if (
|
|
832
|
+
extracted is not None
|
|
833
|
+
and input_row_id is not None
|
|
834
|
+
and self._db is not None
|
|
835
|
+
):
|
|
836
|
+
try:
|
|
837
|
+
response_row_id = await self._db.record_ephemeral_response(
|
|
838
|
+
input_row_id, extracted
|
|
839
|
+
)
|
|
840
|
+
except Exception as exc:
|
|
841
|
+
_log.warning(
|
|
842
|
+
"pty: btw finalize — record_ephemeral_response failed "
|
|
843
|
+
"(session %s, input_row_id=%s): %s",
|
|
844
|
+
self.session_id,
|
|
845
|
+
input_row_id,
|
|
846
|
+
exc,
|
|
847
|
+
)
|
|
848
|
+
else:
|
|
849
|
+
_log.info(
|
|
850
|
+
"pty: btw response persisted (session %s, input_row=%s, "
|
|
851
|
+
"response_row=%s, extracted_len=%d)",
|
|
852
|
+
self.session_id,
|
|
853
|
+
input_row_id,
|
|
854
|
+
response_row_id,
|
|
855
|
+
len(extracted),
|
|
856
|
+
)
|
|
857
|
+
if self._bus is not None and response_row_id is not None:
|
|
858
|
+
self._bus.publish(
|
|
859
|
+
{
|
|
860
|
+
"type": "ephemeral_pair_persisted",
|
|
861
|
+
"session_id": self.session_id,
|
|
862
|
+
"input_id": input_row_id,
|
|
863
|
+
"response_id": response_row_id,
|
|
864
|
+
"kind": "btw",
|
|
865
|
+
}
|
|
866
|
+
)
|
|
867
|
+
status = "completed"
|
|
868
|
+
elif extracted is None:
|
|
869
|
+
# Promoted from DEBUG to WARNING — extraction failure is a
|
|
870
|
+
# real bug worth surfacing in ops logs. The raw buffer is the
|
|
871
|
+
# live PTY output and may contain on-screen secrets, so we only
|
|
872
|
+
# dump it to disk when CLAU_DECODE_DEBUG is set (opt-in); by
|
|
873
|
+
# default we just log its length.
|
|
874
|
+
dump_path: Optional[Path] = None
|
|
875
|
+
if os.environ.get("CLAU_DECODE_DEBUG"):
|
|
876
|
+
dump_path = Path("/tmp") / f"btw-extract-fail-{self.session_id}.bin"
|
|
877
|
+
try:
|
|
878
|
+
dump_path.write_bytes(raw)
|
|
879
|
+
except Exception:
|
|
880
|
+
dump_path = None
|
|
881
|
+
_log.warning(
|
|
882
|
+
"pty: btw finalize — extraction yielded None "
|
|
883
|
+
"(session %s, buffer_len=%d%s)",
|
|
884
|
+
self.session_id,
|
|
885
|
+
len(raw),
|
|
886
|
+
f", dumped to {dump_path}"
|
|
887
|
+
if dump_path
|
|
888
|
+
else " (set CLAU_DECODE_DEBUG to dump raw bytes)",
|
|
889
|
+
)
|
|
890
|
+
status = "failed"
|
|
891
|
+
else:
|
|
892
|
+
status = "failed"
|
|
893
|
+
|
|
894
|
+
except asyncio.CancelledError:
|
|
895
|
+
# Stuck-timeout or shutdown cancelled us — let the timeout handler
|
|
896
|
+
# (or kill) clean up state.
|
|
897
|
+
raise
|
|
898
|
+
except Exception as exc:
|
|
899
|
+
_log.warning(
|
|
900
|
+
"pty: btw finalize raised unexpectedly (session %s): %s",
|
|
901
|
+
self.session_id,
|
|
902
|
+
exc,
|
|
903
|
+
)
|
|
904
|
+
status = "failed"
|
|
905
|
+
finally:
|
|
906
|
+
if status is not None:
|
|
907
|
+
self._publish_submit_completed(
|
|
908
|
+
kind="btw",
|
|
909
|
+
status=status,
|
|
910
|
+
input_row_id=input_row_id,
|
|
911
|
+
response_row_id=response_row_id,
|
|
912
|
+
)
|
|
913
|
+
# 5. Reset channel state regardless of success / failure.
|
|
914
|
+
self._state.expecting_btw_response = False
|
|
915
|
+
self._state.btw_buffer.clear()
|
|
916
|
+
self._state.btw_input_row_id = None
|
|
917
|
+
|
|
918
|
+
_log.debug(
|
|
919
|
+
"pty: btw capture complete (session %s, input_row=%s, response_row=%s)",
|
|
920
|
+
self.session_id,
|
|
921
|
+
input_row_id,
|
|
922
|
+
response_row_id,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
async def _btw_stuck_timeout(self) -> None:
|
|
926
|
+
"""Backstop: if the response-complete marker never arrives, give up.
|
|
927
|
+
|
|
928
|
+
After ``self._btw_stuck_timeout_s`` seconds (default
|
|
929
|
+
:data:`_BTW_STUCK_TIMEOUT_S`), log a warning, attempt extraction on
|
|
930
|
+
whatever's in the buffer (may yield None), persist any extracted
|
|
931
|
+
text, and reset state. Cancelled if _finalize_btw_capture() fires
|
|
932
|
+
first (marker arrived normally).
|
|
933
|
+
|
|
934
|
+
Override ``channel._btw_stuck_timeout_s`` in tests to shorten the
|
|
935
|
+
wait without patching the module-level constant.
|
|
936
|
+
"""
|
|
937
|
+
try:
|
|
938
|
+
await asyncio.sleep(self._btw_stuck_timeout_s)
|
|
939
|
+
except asyncio.CancelledError:
|
|
940
|
+
return
|
|
941
|
+
|
|
942
|
+
_log.warning(
|
|
943
|
+
"pty: btw stuck-modal timeout after %.0fs (session %s); "
|
|
944
|
+
"buffer=%d bytes — forcing finalize",
|
|
945
|
+
self._btw_stuck_timeout_s,
|
|
946
|
+
self.session_id,
|
|
947
|
+
len(self._state.btw_buffer),
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
# Cancel any pending finalize (shouldn't exist if timeout fires,
|
|
951
|
+
# but be safe).
|
|
952
|
+
if self._btw_finalize_task is not None and not self._btw_finalize_task.done():
|
|
953
|
+
self._btw_finalize_task.cancel()
|
|
954
|
+
self._btw_finalize_task = None
|
|
955
|
+
|
|
956
|
+
input_row_id = self._state.btw_input_row_id
|
|
957
|
+
raw = bytes(self._state.btw_buffer)
|
|
958
|
+
extracted = extract_btw_response(raw)
|
|
959
|
+
|
|
960
|
+
response_row_id: Optional[int] = None
|
|
961
|
+
if extracted is not None and input_row_id is not None and self._db is not None:
|
|
962
|
+
try:
|
|
963
|
+
response_row_id = await self._db.record_ephemeral_response(
|
|
964
|
+
input_row_id, extracted
|
|
965
|
+
)
|
|
966
|
+
except Exception as exc:
|
|
967
|
+
_log.warning(
|
|
968
|
+
"pty: btw timeout — record_ephemeral_response failed "
|
|
969
|
+
"(session %s): %s",
|
|
970
|
+
self.session_id,
|
|
971
|
+
exc,
|
|
972
|
+
)
|
|
973
|
+
else:
|
|
974
|
+
if self._bus is not None and response_row_id is not None:
|
|
975
|
+
self._bus.publish(
|
|
976
|
+
{
|
|
977
|
+
"type": "ephemeral_pair_persisted",
|
|
978
|
+
"session_id": self.session_id,
|
|
979
|
+
"input_id": input_row_id,
|
|
980
|
+
"response_id": response_row_id,
|
|
981
|
+
"kind": "btw",
|
|
982
|
+
}
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
self._publish_submit_completed(
|
|
986
|
+
kind="btw",
|
|
987
|
+
status="timed_out",
|
|
988
|
+
input_row_id=input_row_id,
|
|
989
|
+
response_row_id=response_row_id,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
# Reset state.
|
|
993
|
+
self._state.expecting_btw_response = False
|
|
994
|
+
self._state.btw_buffer.clear()
|
|
995
|
+
self._state.btw_input_row_id = None
|
|
996
|
+
|
|
997
|
+
# ------------------------------------------------------------------
|
|
998
|
+
# Introspection
|
|
999
|
+
# ------------------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
def is_alive(self) -> bool:
|
|
1002
|
+
"""True iff the subprocess is running and the master fd is open."""
|
|
1003
|
+
proc = self._state.proc
|
|
1004
|
+
if proc is None or self._state.dead:
|
|
1005
|
+
return False
|
|
1006
|
+
return proc.returncode is None
|
|
1007
|
+
|
|
1008
|
+
def last_activity_ms(self) -> int:
|
|
1009
|
+
"""Max of last input and last output timestamps."""
|
|
1010
|
+
return max(self._state.last_input_ms, self._state.last_pty_output_ms)
|
|
1011
|
+
|
|
1012
|
+
def last_input_ms(self) -> int:
|
|
1013
|
+
"""Timestamp of the most recent ``write()``, in ms since epoch."""
|
|
1014
|
+
return self._state.last_input_ms
|
|
1015
|
+
|
|
1016
|
+
def last_pty_output_ms(self) -> int:
|
|
1017
|
+
"""Timestamp of the most recent drained chunk, in ms since epoch."""
|
|
1018
|
+
return self._state.last_pty_output_ms
|
|
1019
|
+
|
|
1020
|
+
def last_pty_output_seq(self) -> int:
|
|
1021
|
+
"""Monotonic count of drained PTY output chunks."""
|
|
1022
|
+
return self._state.last_pty_output_seq
|
|
1023
|
+
|
|
1024
|
+
def output_snapshot(self) -> bytes:
|
|
1025
|
+
"""Copy of the bounded PTY output ring for Native View hydration."""
|
|
1026
|
+
return bytes(self._state.ring)
|
|
1027
|
+
|
|
1028
|
+
def output_snapshot_complete(self) -> bool:
|
|
1029
|
+
"""True iff the output ring has not dropped earlier terminal bytes."""
|
|
1030
|
+
return self._state.ring_complete
|
|
1031
|
+
|
|
1032
|
+
def dimensions(self) -> tuple[int, int]:
|
|
1033
|
+
"""Current terminal dimensions as ``(rows, cols)``."""
|
|
1034
|
+
return self._rows, self._cols
|
|
1035
|
+
|
|
1036
|
+
# ------------------------------------------------------------------
|
|
1037
|
+
# Window size
|
|
1038
|
+
# ------------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
def resize(self, rows: int, cols: int) -> None:
|
|
1041
|
+
"""Update terminal window size on the live PTY.
|
|
1042
|
+
|
|
1043
|
+
``TIOCSWINSZ`` applied to the master fd propagates to the child
|
|
1044
|
+
as a SIGWINCH — the TUI re-lays-out automatically.
|
|
1045
|
+
"""
|
|
1046
|
+
if rows <= 0 or cols <= 0:
|
|
1047
|
+
raise ValueError(f"invalid window size rows={rows} cols={cols}")
|
|
1048
|
+
# Only a COLUMN change can garble a later ring replay: the captured
|
|
1049
|
+
# bytes were wrapped at the old width and xterm would re-wrap them
|
|
1050
|
+
# wrong. A ROW change is lossless — xterm reflows scrollback on
|
|
1051
|
+
# height changes — so the ring must survive it. Clearing on every
|
|
1052
|
+
# dimension change was wiping history precisely when it mattered:
|
|
1053
|
+
# the FE's post-spawn resize always bumps the row count (spawn is
|
|
1054
|
+
# DEFAULT_ROWS → the fitted viewport height) ~1s into claude's
|
|
1055
|
+
# multi-second history render, so the clear discarded the oldest
|
|
1056
|
+
# history (the top of the conversation) from the ring — the
|
|
1057
|
+
# "scroll up but never reach the top" bug on re-attach.
|
|
1058
|
+
cols_changed = self._cols != cols
|
|
1059
|
+
self._rows = rows
|
|
1060
|
+
self._cols = cols
|
|
1061
|
+
if cols_changed:
|
|
1062
|
+
self._state.ring.clear()
|
|
1063
|
+
self._state.ring_complete = True
|
|
1064
|
+
if self._state.master_fd < 0:
|
|
1065
|
+
return
|
|
1066
|
+
try:
|
|
1067
|
+
_set_winsize(self._state.master_fd, rows, cols)
|
|
1068
|
+
except OSError as exc:
|
|
1069
|
+
_log.warning(
|
|
1070
|
+
"pty: resize failed (session %s): %s",
|
|
1071
|
+
self.session_id,
|
|
1072
|
+
exc,
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
# ------------------------------------------------------------------
|
|
1076
|
+
# Teardown
|
|
1077
|
+
# ------------------------------------------------------------------
|
|
1078
|
+
|
|
1079
|
+
async def kill(self) -> None:
|
|
1080
|
+
"""Tear the channel down: SIGINT, double-Ctrl-C, SIGKILL.
|
|
1081
|
+
|
|
1082
|
+
Order matters per Phase 0 finding:
|
|
1083
|
+
1. ``loop.remove_reader(master_fd)``
|
|
1084
|
+
2. SIGINT, wait up to 3s
|
|
1085
|
+
3. write ``\\x03\\x03`` to master, wait up to 3s
|
|
1086
|
+
4. SIGKILL
|
|
1087
|
+
5. ``os.close(master_fd)``
|
|
1088
|
+
6. ``await proc.wait()``
|
|
1089
|
+
Closing the master fd BEFORE the final ``wait()`` prevents a
|
|
1090
|
+
shutdown hang where claude blocks on a write to its now-orphaned
|
|
1091
|
+
controlling tty.
|
|
1092
|
+
"""
|
|
1093
|
+
if self._state.proc is None:
|
|
1094
|
+
# start() never completed (or kill() already ran).
|
|
1095
|
+
self._close_master_fd()
|
|
1096
|
+
return
|
|
1097
|
+
|
|
1098
|
+
# 1. Stop draining first — we're about to tear the fd down and any
|
|
1099
|
+
# in-flight callback would race the close.
|
|
1100
|
+
if self._state.reader_registered and self._state.master_fd >= 0:
|
|
1101
|
+
try:
|
|
1102
|
+
loop = asyncio.get_running_loop()
|
|
1103
|
+
loop.remove_reader(self._state.master_fd)
|
|
1104
|
+
except (RuntimeError, ValueError):
|
|
1105
|
+
pass
|
|
1106
|
+
self._state.reader_registered = False
|
|
1107
|
+
|
|
1108
|
+
proc = self._state.proc
|
|
1109
|
+
|
|
1110
|
+
# 2. SIGINT — claude's TUI handles this cleanly (single Ctrl-C
|
|
1111
|
+
# cancels in-flight tool calls; second sends the goodbye).
|
|
1112
|
+
if proc.returncode is None:
|
|
1113
|
+
try:
|
|
1114
|
+
proc.send_signal(signal.SIGINT)
|
|
1115
|
+
except ProcessLookupError:
|
|
1116
|
+
pass
|
|
1117
|
+
try:
|
|
1118
|
+
await asyncio.wait_for(
|
|
1119
|
+
asyncio.shield(proc.wait()), timeout=_KILL_SIGINT_WAIT_S
|
|
1120
|
+
)
|
|
1121
|
+
except asyncio.TimeoutError:
|
|
1122
|
+
pass
|
|
1123
|
+
|
|
1124
|
+
# 3. Double Ctrl-C through the PTY — bypasses signal handling and
|
|
1125
|
+
# tells the TUI's input layer "I really mean it".
|
|
1126
|
+
if proc.returncode is None and self._state.master_fd >= 0:
|
|
1127
|
+
try:
|
|
1128
|
+
os.write(self._state.master_fd, b"\x03\x03")
|
|
1129
|
+
except OSError:
|
|
1130
|
+
pass
|
|
1131
|
+
try:
|
|
1132
|
+
await asyncio.wait_for(
|
|
1133
|
+
asyncio.shield(proc.wait()), timeout=_KILL_CTRLC_WAIT_S
|
|
1134
|
+
)
|
|
1135
|
+
except asyncio.TimeoutError:
|
|
1136
|
+
pass
|
|
1137
|
+
|
|
1138
|
+
# 4. SIGKILL — nothing left to be polite about.
|
|
1139
|
+
if proc.returncode is None:
|
|
1140
|
+
try:
|
|
1141
|
+
proc.kill()
|
|
1142
|
+
except ProcessLookupError:
|
|
1143
|
+
pass
|
|
1144
|
+
|
|
1145
|
+
# 5. Close master fd BEFORE the final wait().
|
|
1146
|
+
self._close_master_fd()
|
|
1147
|
+
|
|
1148
|
+
# 6. Reap.
|
|
1149
|
+
try:
|
|
1150
|
+
await proc.wait()
|
|
1151
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
1152
|
+
_log.warning(
|
|
1153
|
+
"pty: wait after kill raised (session %s): %s",
|
|
1154
|
+
self.session_id,
|
|
1155
|
+
exc,
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
# Drop ourselves from the ownership-allowlist now that the pid
|
|
1159
|
+
# is reaped. Discard (not remove) — start() may not have added
|
|
1160
|
+
# it if the spawn errored early.
|
|
1161
|
+
_OWN_CLAUDE_PIDS.discard(proc.pid)
|
|
1162
|
+
|
|
1163
|
+
# Phase 1 — release the lock sidecar AFTER the subprocess is
|
|
1164
|
+
# reaped. Releasing earlier would let a second clau-decode
|
|
1165
|
+
# acquire while our claude is still partway through its
|
|
1166
|
+
# tear-down JSONL writes.
|
|
1167
|
+
if self._lock_sidecar is not None:
|
|
1168
|
+
try:
|
|
1169
|
+
self._lock_sidecar.release()
|
|
1170
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
1171
|
+
_log.debug(
|
|
1172
|
+
"pty: lock release at kill raised (session %s): %s",
|
|
1173
|
+
self.session_id,
|
|
1174
|
+
exc,
|
|
1175
|
+
)
|
|
1176
|
+
self._lock_sidecar = None
|
|
1177
|
+
|
|
1178
|
+
self._state.dead = True
|
|
1179
|
+
_log.info("pty: killed (session %s, rc=%s)", self.session_id, proc.returncode)
|
|
1180
|
+
|
|
1181
|
+
def _close_master_fd(self) -> None:
|
|
1182
|
+
if self._state.master_fd >= 0:
|
|
1183
|
+
try:
|
|
1184
|
+
os.close(self._state.master_fd)
|
|
1185
|
+
except OSError:
|
|
1186
|
+
pass
|
|
1187
|
+
self._state.master_fd = -1
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
# ---------------------------------------------------------------------------
|
|
1191
|
+
# PtyManager
|
|
1192
|
+
# ---------------------------------------------------------------------------
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _build_argv(
|
|
1196
|
+
*,
|
|
1197
|
+
bin_name: str,
|
|
1198
|
+
session_id: str,
|
|
1199
|
+
model: str,
|
|
1200
|
+
permission_mode: str,
|
|
1201
|
+
new_chat: bool,
|
|
1202
|
+
) -> list[str]:
|
|
1203
|
+
"""Compose argv per the plan's session-lifecycle rules.
|
|
1204
|
+
|
|
1205
|
+
new_chat=True -> [bin, --session-id, sid, --permission-mode, mode,
|
|
1206
|
+
(--model, model)?]
|
|
1207
|
+
new_chat=False -> [bin, --resume, sid, --permission-mode, mode,
|
|
1208
|
+
(--model, model)?]
|
|
1209
|
+
|
|
1210
|
+
For new chats we pass ``--session-id`` so claude writes its JSONL at the
|
|
1211
|
+
id we minted in ``pending_sessions``; without this claude would mint its
|
|
1212
|
+
own UUID and the sidebar would show both our pending placeholder and the
|
|
1213
|
+
real session as separate entries.
|
|
1214
|
+
"""
|
|
1215
|
+
argv: list[str] = [bin_name]
|
|
1216
|
+
if new_chat:
|
|
1217
|
+
argv.extend(["--session-id", session_id])
|
|
1218
|
+
else:
|
|
1219
|
+
argv.extend(["--resume", session_id])
|
|
1220
|
+
argv.extend(["--permission-mode", permission_mode])
|
|
1221
|
+
if model:
|
|
1222
|
+
argv.extend(["--model", model])
|
|
1223
|
+
return argv
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
@dataclass
|
|
1227
|
+
class _FocusParams:
|
|
1228
|
+
"""Cached focus arguments so ``submit()`` can auto-respawn a dead channel.
|
|
1229
|
+
|
|
1230
|
+
A channel can die for two reasons in Phase 1:
|
|
1231
|
+
- idle-timer kill (planned, after 5 minutes of inactivity)
|
|
1232
|
+
- subprocess crash / unexpected exit (unplanned)
|
|
1233
|
+
Both want the next ``submit()`` on the session to silently respawn
|
|
1234
|
+
with the parameters most recently provided to ``focus()``. We cache
|
|
1235
|
+
them here rather than asking the caller to re-supply on every submit.
|
|
1236
|
+
Auto-respawn always uses ``new_chat=False`` (we ``--resume`` an
|
|
1237
|
+
existing session id); the original ``new_chat`` value is recorded
|
|
1238
|
+
only for completeness.
|
|
1239
|
+
"""
|
|
1240
|
+
|
|
1241
|
+
cwd: str
|
|
1242
|
+
bin_name: str
|
|
1243
|
+
model: str
|
|
1244
|
+
permission_mode: str
|
|
1245
|
+
new_chat: bool
|
|
1246
|
+
# Initial PTY row count. The Native view fits the terminal to the pane
|
|
1247
|
+
# BEFORE spawning and passes the fitted rows here, so claude renders at the
|
|
1248
|
+
# final height from the first frame — no spawn-at-40-then-resize-to-N grow,
|
|
1249
|
+
# which left stale content in the revealed rows (smear) and stranded
|
|
1250
|
+
# claude's footer mid-screen (the bottom gap). Cols stay native_pty_cols
|
|
1251
|
+
# (already the spawn width). Defaults to DEFAULT_ROWS for non-native callers.
|
|
1252
|
+
rows: int = DEFAULT_ROWS
|
|
1253
|
+
# Absolute JSONL path for ownership conflict detection. ``None`` for
|
|
1254
|
+
# brand-new sessions whose JSONL doesn't exist yet (no one to
|
|
1255
|
+
# conflict with) and for any caller that hasn't plumbed the path
|
|
1256
|
+
# through — detection is skipped in that case.
|
|
1257
|
+
jsonl_path: Optional[Path] = None
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@dataclass
|
|
1261
|
+
class _ManagedChannel:
|
|
1262
|
+
"""PtyManager's per-session bookkeeping around a single ``PtyChannel``."""
|
|
1263
|
+
|
|
1264
|
+
channel: PtyChannel
|
|
1265
|
+
focus_params: _FocusParams
|
|
1266
|
+
idle_warn_handle: Optional[asyncio.TimerHandle] = None
|
|
1267
|
+
idle_kill_handle: Optional[asyncio.TimerHandle] = None
|
|
1268
|
+
idle_warn_at_ms: int = 0
|
|
1269
|
+
idle_kill_at_ms: int = 0
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
class PtyManager:
|
|
1273
|
+
"""Singleton attached to ``app.state.pty_manager``.
|
|
1274
|
+
|
|
1275
|
+
Phase 1 responsibilities:
|
|
1276
|
+
- lazy-spawn a ``PtyChannel`` on ``focus()`` (or auto on ``submit()``)
|
|
1277
|
+
- idle-timer with SSE warn at ``idle_warn_s`` and kill at
|
|
1278
|
+
``idle_timeout_s``
|
|
1279
|
+
- clean ``shutdown()`` in the FastAPI lifespan ``finally`` block
|
|
1280
|
+
|
|
1281
|
+
Phase 3 added a per-session ``asyncio.Lock`` keyed on session id.
|
|
1282
|
+
``focus``/``submit``/``kill``/``unfocus``/``switch_model`` acquire
|
|
1283
|
+
it first, then the global ``self._lock``. Two ops on the same sid
|
|
1284
|
+
serialize (so two browser tabs submitting at once don't interleave
|
|
1285
|
+
PTY writes); cross-session ops stay parallel.
|
|
1286
|
+
|
|
1287
|
+
The ``db`` parameter is stored but unused in Phase 1 — Phase 2 will
|
|
1288
|
+
use it for ``/btw`` ephemeral capture (both input and PTY-output
|
|
1289
|
+
sides) and Phase 4 for stuck-session detection via
|
|
1290
|
+
``Session.updated_at`` lookup.
|
|
1291
|
+
"""
|
|
1292
|
+
|
|
1293
|
+
def __init__(
|
|
1294
|
+
self,
|
|
1295
|
+
db: "Database",
|
|
1296
|
+
bus: "EventBroadcaster",
|
|
1297
|
+
*,
|
|
1298
|
+
idle_timeout_s: float = DEFAULT_IDLE_TIMEOUT_S,
|
|
1299
|
+
idle_warn_s: float = DEFAULT_IDLE_WARN_S,
|
|
1300
|
+
blurred_idle_timeout_s: float = BLURRED_IDLE_TIMEOUT_S,
|
|
1301
|
+
ui_endpoint: Optional[str] = None,
|
|
1302
|
+
) -> None:
|
|
1303
|
+
if idle_warn_s >= idle_timeout_s:
|
|
1304
|
+
raise ValueError(
|
|
1305
|
+
f"idle_warn_s ({idle_warn_s}) must be < "
|
|
1306
|
+
f"idle_timeout_s ({idle_timeout_s})"
|
|
1307
|
+
)
|
|
1308
|
+
self._db = db
|
|
1309
|
+
self._bus = bus
|
|
1310
|
+
# Phase-1 lock sidecar metadata. ``ui_endpoint`` is plumbed
|
|
1311
|
+
# into each PtyChannel so a peer clau-decode reading the lock
|
|
1312
|
+
# can render a "open in UI at …" link in its take-over banner.
|
|
1313
|
+
# Falls back to ``None`` for tests / non-HTTP callers.
|
|
1314
|
+
self._ui_endpoint = ui_endpoint
|
|
1315
|
+
self._idle_timeout_s = idle_timeout_s
|
|
1316
|
+
self._idle_warn_s = idle_warn_s
|
|
1317
|
+
self._blurred_idle_timeout_s = blurred_idle_timeout_s
|
|
1318
|
+
self._channels: dict[str, _ManagedChannel] = {}
|
|
1319
|
+
# Most recent focus params per session — outlives the channel so
|
|
1320
|
+
# auto-respawn after an idle kill can recover transparently. The
|
|
1321
|
+
# cached entry persists until explicit ``kill()`` or ``shutdown()``.
|
|
1322
|
+
self._last_focus: dict[str, _FocusParams] = {}
|
|
1323
|
+
# Top-level lock guarding channel-table mutations. Acquired
|
|
1324
|
+
# INSIDE the per-session lock so the global section is short
|
|
1325
|
+
# (dict mutations only). Spawning + PTY writes happen outside.
|
|
1326
|
+
self._lock = asyncio.Lock()
|
|
1327
|
+
# Phase 3 — per-session asyncio.Lock keyed on session id.
|
|
1328
|
+
# Wraps the full focus/submit/kill body so concurrent ops on
|
|
1329
|
+
# the same sid serialize while cross-session ops stay parallel.
|
|
1330
|
+
# Allocated lazily by ``_session_lock``; entries are not
|
|
1331
|
+
# removed on kill (cheap; bounded by sessions ever-touched).
|
|
1332
|
+
self._session_locks: dict[str, asyncio.Lock] = {}
|
|
1333
|
+
self._shutdown_started = False
|
|
1334
|
+
# Per-session input watchdog tasks. After each submit() we spawn a
|
|
1335
|
+
# short-lived task that emits SSE pty_input_acknowledged once the
|
|
1336
|
+
# TUI has echoed something (~500ms) or pty_input_stalled if
|
|
1337
|
+
# nothing came back within ~5s. The frontend uses these to drive
|
|
1338
|
+
# the "Thinking" indicator without blind timers. A new submit
|
|
1339
|
+
# cancels the prior watchdog so we only ever report on the latest.
|
|
1340
|
+
self._input_watchdogs: dict[str, asyncio.Task] = {}
|
|
1341
|
+
# Authoritative native PTY width (cols). Single source of truth for
|
|
1342
|
+
# both the spawn winsize and what the browser terminal renders at.
|
|
1343
|
+
# Seeded from AppConfig.native_pty_cols at startup and refreshed on
|
|
1344
|
+
# config update via set_native_cols(); falls back to DEFAULT_COLS.
|
|
1345
|
+
self._native_cols: int = DEFAULT_COLS
|
|
1346
|
+
# The session currently displayed in the native view. Only this
|
|
1347
|
+
# session's PTY output chunks are broadcast to SSE; all others are
|
|
1348
|
+
# silently dropped to prevent a JSON.parse firehose on the main thread
|
|
1349
|
+
# that scales with live-PTY count. Set by focus() and
|
|
1350
|
+
# native_snapshot(); cleared by unfocus().
|
|
1351
|
+
self._active_session_id: Optional[str] = None
|
|
1352
|
+
# Output coalescing (see _scan_chunk_for_hitl / _flush_output). claude
|
|
1353
|
+
# writes a TUI repaint as many tiny PTY writes; we batch the raw reads
|
|
1354
|
+
# per session and flush one combined output chunk + at most one state
|
|
1355
|
+
# reclassification per _OUTPUT_COALESCE_S window.
|
|
1356
|
+
self._pending_output: dict[str, bytearray] = {}
|
|
1357
|
+
self._output_flush_handles: dict[str, asyncio.TimerHandle] = {}
|
|
1358
|
+
# Last (state, decoded_input_safe) published per session — pty_native_state
|
|
1359
|
+
# is emitted only when this changes, so a scroll (state unchanged) emits
|
|
1360
|
+
# none instead of one per chunk.
|
|
1361
|
+
self._last_native_state: dict[str, tuple[str, bool]] = {}
|
|
1362
|
+
|
|
1363
|
+
def set_native_cols(self, cols: int) -> None:
|
|
1364
|
+
"""Set the width new PTYs spawn at (from AppConfig.native_pty_cols).
|
|
1365
|
+
|
|
1366
|
+
Affects only channels spawned after this call — live channels keep
|
|
1367
|
+
their width until respawn (changing a running PTY's width would
|
|
1368
|
+
force Claude to reflow committed scrollback).
|
|
1369
|
+
"""
|
|
1370
|
+
if cols > 0:
|
|
1371
|
+
self._native_cols = cols
|
|
1372
|
+
|
|
1373
|
+
# ------------------------------------------------------------------
|
|
1374
|
+
# Locking helpers
|
|
1375
|
+
# ------------------------------------------------------------------
|
|
1376
|
+
|
|
1377
|
+
def _session_lock(self, session_id: str) -> asyncio.Lock:
|
|
1378
|
+
"""Phase-3 per-session lock. Lazily allocated; never removed.
|
|
1379
|
+
|
|
1380
|
+
Lock ordering is ALWAYS ``_session_lock`` → ``self._lock``. The
|
|
1381
|
+
global lock guards the per-session-dict mutations and timer
|
|
1382
|
+
bookkeeping; the per-session lock wraps the whole op so two
|
|
1383
|
+
submits on the same sid serialize their PTY writes.
|
|
1384
|
+
|
|
1385
|
+
Entries persist for the manager's lifetime — asyncio.Lock is
|
|
1386
|
+
cheap (≈ 200 B), sessions touched in a day are bounded, and
|
|
1387
|
+
cleanup-on-kill races with a fresh focus that would re-allocate
|
|
1388
|
+
a different Lock instance and silently lose the serialization.
|
|
1389
|
+
"""
|
|
1390
|
+
lock = self._session_locks.get(session_id)
|
|
1391
|
+
if lock is None:
|
|
1392
|
+
lock = asyncio.Lock()
|
|
1393
|
+
self._session_locks[session_id] = lock
|
|
1394
|
+
return lock
|
|
1395
|
+
|
|
1396
|
+
# ------------------------------------------------------------------
|
|
1397
|
+
# Public API
|
|
1398
|
+
# ------------------------------------------------------------------
|
|
1399
|
+
|
|
1400
|
+
async def focus(
|
|
1401
|
+
self,
|
|
1402
|
+
session_id: str,
|
|
1403
|
+
*,
|
|
1404
|
+
cwd: str,
|
|
1405
|
+
bin_name: str,
|
|
1406
|
+
model: str,
|
|
1407
|
+
permission_mode: str,
|
|
1408
|
+
new_chat: bool,
|
|
1409
|
+
rows: int = DEFAULT_ROWS,
|
|
1410
|
+
jsonl_path: Optional[Path] = None,
|
|
1411
|
+
) -> None:
|
|
1412
|
+
"""Ensure a live PTY channel exists for ``session_id``.
|
|
1413
|
+
|
|
1414
|
+
Idempotency: if a channel is already alive for this session AND
|
|
1415
|
+
its spawn-time ``bin_name`` / ``cwd`` / ``model`` / ``permission_mode``
|
|
1416
|
+
match the incoming request, this is a true no-op — we don't even
|
|
1417
|
+
touch the idle timer. The frontend was observed firing focus()
|
|
1418
|
+
eagerly on every input click; respawning (or even just bumping
|
|
1419
|
+
timers) on each call contributes to orphan PTY claudes and resets
|
|
1420
|
+
idle bookkeeping the user didn't ask to reset.
|
|
1421
|
+
|
|
1422
|
+
If the channel is dead (or absent), or if any spawn-relevant
|
|
1423
|
+
param differs from the live channel's, fall through to the
|
|
1424
|
+
existing spawn/swap logic. ``new_chat`` is intentionally NOT
|
|
1425
|
+
part of the match — once a session is live the id is real on
|
|
1426
|
+
disk and ``--resume`` semantics apply regardless.
|
|
1427
|
+
|
|
1428
|
+
A model mismatch on an already-live channel is intentionally
|
|
1429
|
+
deferred to ``submit()``, which has the in-flight guard needed to
|
|
1430
|
+
decide whether it is safe to send claude's ``/model`` command.
|
|
1431
|
+
"""
|
|
1432
|
+
async with self._session_lock(session_id):
|
|
1433
|
+
async with self._lock:
|
|
1434
|
+
if self._shutdown_started:
|
|
1435
|
+
raise RuntimeError("PtyManager is shutting down")
|
|
1436
|
+
|
|
1437
|
+
# Fast-path: live channel with matching spawn params → no
|
|
1438
|
+
# respawn. We still reset the idle timer so a revisit after
|
|
1439
|
+
# the FE-initiated short blur-kill window restores the full
|
|
1440
|
+
# ``idle_timeout_s`` lease. Skipping the reset would leave a
|
|
1441
|
+
# previously-blurred channel scheduled to die ~30s after
|
|
1442
|
+
# blur, even if the user has navigated back to it.
|
|
1443
|
+
existing = self._channels.get(session_id)
|
|
1444
|
+
if (
|
|
1445
|
+
existing is not None
|
|
1446
|
+
and existing.channel.is_alive()
|
|
1447
|
+
and existing.focus_params.bin_name == bin_name
|
|
1448
|
+
and existing.focus_params.cwd == cwd
|
|
1449
|
+
and existing.focus_params.model == model
|
|
1450
|
+
and existing.focus_params.permission_mode == permission_mode
|
|
1451
|
+
):
|
|
1452
|
+
foreign = _session_conflict_pids(session_id, jsonl_path)
|
|
1453
|
+
if foreign:
|
|
1454
|
+
raise PtyOwnershipConflict(foreign)
|
|
1455
|
+
self._set_active_session_locked(session_id)
|
|
1456
|
+
self._reset_idle_timer_locked(session_id)
|
|
1457
|
+
return
|
|
1458
|
+
|
|
1459
|
+
# Phase 0 — ownership conflict detection. Runs only when
|
|
1460
|
+
# the fast-path above didn't return (i.e. we're about to
|
|
1461
|
+
# spawn or respawn). For a foreign-attached session,
|
|
1462
|
+
# refuse rather than spawn a racing claude; the FE
|
|
1463
|
+
# surfaces this as a take-over banner.
|
|
1464
|
+
foreign = _session_conflict_pids(session_id, jsonl_path)
|
|
1465
|
+
if foreign:
|
|
1466
|
+
raise PtyOwnershipConflict(foreign)
|
|
1467
|
+
|
|
1468
|
+
params = _FocusParams(
|
|
1469
|
+
cwd=cwd,
|
|
1470
|
+
bin_name=bin_name,
|
|
1471
|
+
model=model,
|
|
1472
|
+
permission_mode=permission_mode,
|
|
1473
|
+
new_chat=new_chat,
|
|
1474
|
+
rows=rows,
|
|
1475
|
+
jsonl_path=jsonl_path,
|
|
1476
|
+
)
|
|
1477
|
+
self._last_focus[session_id] = params
|
|
1478
|
+
await self._ensure_channel_locked(session_id, params)
|
|
1479
|
+
self._set_active_session_locked(session_id)
|
|
1480
|
+
self._reset_idle_timer_locked(session_id)
|
|
1481
|
+
|
|
1482
|
+
async def unfocus(self, session_id: str) -> None:
|
|
1483
|
+
"""Note that input lost focus — shorten the idle-kill window.
|
|
1484
|
+
|
|
1485
|
+
The channel stays alive (refocusing within the window costs no
|
|
1486
|
+
respawn), but the next idle-kill is brought close so orphan PTYs
|
|
1487
|
+
don't accumulate during sidebar navigation. The FE wires this to
|
|
1488
|
+
the ChatInput cleanup so every session-switch marks the previous
|
|
1489
|
+
session as nav'd-away.
|
|
1490
|
+
|
|
1491
|
+
No-op if the channel is already gone. If a previously-scheduled
|
|
1492
|
+
kill was further out (typical: a fresh focus set the 5-minute
|
|
1493
|
+
timer), the timer is shortened. If it was already shorter
|
|
1494
|
+
(multiple blurs in a row), the existing schedule is preserved
|
|
1495
|
+
to avoid pushing the kill further out.
|
|
1496
|
+
"""
|
|
1497
|
+
async with self._session_lock(session_id):
|
|
1498
|
+
async with self._lock:
|
|
1499
|
+
managed = self._channels.get(session_id)
|
|
1500
|
+
if managed is None:
|
|
1501
|
+
return
|
|
1502
|
+
# Keep the most-recent (last-focused / on-screen) session's PTY
|
|
1503
|
+
# alive so navigating to another session and back doesn't force
|
|
1504
|
+
# a respawn. It is reaped only when a different native session
|
|
1505
|
+
# supersedes it (_set_active_session_locked) or the tab closes
|
|
1506
|
+
# (pagehide → ptyKillKeepalive). Older, non-active sessions
|
|
1507
|
+
# still get the fast blurred kill here.
|
|
1508
|
+
if self._active_session_id == session_id:
|
|
1509
|
+
return
|
|
1510
|
+
self._shorten_idle_kill_locked(session_id)
|
|
1511
|
+
|
|
1512
|
+
def _shorten_idle_kill_locked(self, session_id: str) -> None:
|
|
1513
|
+
"""Re-schedule the idle-kill timer to ``_blurred_idle_timeout_s``,
|
|
1514
|
+
but only if the currently-scheduled kill is further out than that.
|
|
1515
|
+
Idempotent across repeated blurs."""
|
|
1516
|
+
managed = self._channels.get(session_id)
|
|
1517
|
+
if managed is None:
|
|
1518
|
+
return
|
|
1519
|
+
loop = asyncio.get_running_loop()
|
|
1520
|
+
now_ms = _now_ms()
|
|
1521
|
+
target_ms = now_ms + int(self._blurred_idle_timeout_s * 1000)
|
|
1522
|
+
if managed.idle_kill_at_ms and managed.idle_kill_at_ms <= target_ms:
|
|
1523
|
+
# Already scheduled to die sooner; don't extend.
|
|
1524
|
+
return
|
|
1525
|
+
self._cancel_idle_timers(managed)
|
|
1526
|
+
# No warning event for blur — straight to the kill schedule.
|
|
1527
|
+
managed.idle_warn_at_ms = 0
|
|
1528
|
+
managed.idle_kill_at_ms = target_ms
|
|
1529
|
+
managed.idle_kill_handle = loop.call_later(
|
|
1530
|
+
self._blurred_idle_timeout_s, self._on_idle_kill, session_id
|
|
1531
|
+
)
|
|
1532
|
+
|
|
1533
|
+
def _set_active_session_locked(self, session_id: str) -> None:
|
|
1534
|
+
"""Mark ``session_id`` as the session currently on screen.
|
|
1535
|
+
|
|
1536
|
+
Reap the PREVIOUS most-recent session's PTY (if different) by
|
|
1537
|
+
shortening it to the blurred window, so opening a new native view
|
|
1538
|
+
cleans up the one it replaces. The newly-active session is then
|
|
1539
|
+
protected from idle-kill (``_on_idle_kill`` re-arms it) until it is
|
|
1540
|
+
itself superseded or the tab closes — so navigating to a non-native
|
|
1541
|
+
session and back finds the PTY still alive (no respawn).
|
|
1542
|
+
"""
|
|
1543
|
+
prev = self._active_session_id
|
|
1544
|
+
if prev is not None and prev != session_id:
|
|
1545
|
+
self._shorten_idle_kill_locked(prev)
|
|
1546
|
+
self._active_session_id = session_id
|
|
1547
|
+
|
|
1548
|
+
async def submit(
|
|
1549
|
+
self,
|
|
1550
|
+
session_id: str,
|
|
1551
|
+
content: str,
|
|
1552
|
+
) -> None:
|
|
1553
|
+
"""Write ``content`` to the PTY as a TUI message submission.
|
|
1554
|
+
|
|
1555
|
+
Translation:
|
|
1556
|
+
- in-message ``\\n`` characters become Ctrl+J (0x0a) bytes
|
|
1557
|
+
- a trailing ``\\r`` (0x0d) is appended to submit
|
|
1558
|
+
|
|
1559
|
+
If no live channel exists (e.g. idle-killed since last activity),
|
|
1560
|
+
we transparently respawn one using the focus parameters cached on
|
|
1561
|
+
the most recent ``focus()`` call. The respawn uses
|
|
1562
|
+
``new_chat=False`` because the session id already exists on disk;
|
|
1563
|
+
callers that need to mint a brand-new session must call
|
|
1564
|
+
``focus(new_chat=True)`` themselves. If no focus has ever happened
|
|
1565
|
+
for this session, we raise — the server route is responsible for
|
|
1566
|
+
ensuring an initial focus.
|
|
1567
|
+
"""
|
|
1568
|
+
# Phase 2 — /btw ephemeral capture: detect a leading /btw at the
|
|
1569
|
+
# TOP of submit(), then persist its user row only after we know no
|
|
1570
|
+
# prior /btw capture is in flight. The DB write still commits before
|
|
1571
|
+
# bytes reach the PTY; the guard prevents duplicate orphan rows and
|
|
1572
|
+
# slash commands being swallowed by the active /btw modal.
|
|
1573
|
+
is_btw = is_btw_input(content)
|
|
1574
|
+
is_slash = content.lstrip().startswith("/") and not is_btw
|
|
1575
|
+
btw_input_row_id: Optional[int] = None
|
|
1576
|
+
|
|
1577
|
+
async with self._session_lock(session_id):
|
|
1578
|
+
async with self._lock:
|
|
1579
|
+
if self._shutdown_started:
|
|
1580
|
+
raise RuntimeError("PtyManager is shutting down")
|
|
1581
|
+
managed = self._channels.get(session_id)
|
|
1582
|
+
if managed is None or not managed.channel.is_alive():
|
|
1583
|
+
params = self._last_focus.get(session_id)
|
|
1584
|
+
if params is None:
|
|
1585
|
+
raise RuntimeError(
|
|
1586
|
+
f"no PTY channel and no cached focus params for "
|
|
1587
|
+
f"session {session_id}; call focus() first"
|
|
1588
|
+
)
|
|
1589
|
+
# Auto-respawn: never start a NEW chat on submit — the
|
|
1590
|
+
# session id is real and on disk, so always --resume.
|
|
1591
|
+
respawn = _FocusParams(
|
|
1592
|
+
cwd=params.cwd,
|
|
1593
|
+
bin_name=params.bin_name,
|
|
1594
|
+
model=params.model,
|
|
1595
|
+
permission_mode=params.permission_mode,
|
|
1596
|
+
new_chat=False,
|
|
1597
|
+
rows=params.rows,
|
|
1598
|
+
jsonl_path=params.jsonl_path,
|
|
1599
|
+
)
|
|
1600
|
+
# Phase 0 — same ownership check focus() runs. Submit
|
|
1601
|
+
# auto-respawn is a spawn path; if a foreign claude
|
|
1602
|
+
# attached to the session during our idle-kill window,
|
|
1603
|
+
# refuse.
|
|
1604
|
+
foreign = _session_conflict_pids(session_id, respawn.jsonl_path)
|
|
1605
|
+
if foreign:
|
|
1606
|
+
raise PtyOwnershipConflict(foreign)
|
|
1607
|
+
self._last_focus[session_id] = respawn
|
|
1608
|
+
managed = await self._ensure_channel_locked(session_id, respawn)
|
|
1609
|
+
else:
|
|
1610
|
+
foreign = _session_conflict_pids(
|
|
1611
|
+
session_id,
|
|
1612
|
+
managed.focus_params.jsonl_path,
|
|
1613
|
+
)
|
|
1614
|
+
if foreign:
|
|
1615
|
+
raise PtyOwnershipConflict(foreign)
|
|
1616
|
+
payload = self._encode_submit(content)
|
|
1617
|
+
channel = managed.channel
|
|
1618
|
+
self._reset_idle_timer_locked(session_id)
|
|
1619
|
+
|
|
1620
|
+
if channel._state.expecting_btw_response:
|
|
1621
|
+
raise PtySubmitInFlight(
|
|
1622
|
+
"A /btw response is still being captured. Wait for it "
|
|
1623
|
+
"to finish before sending another message."
|
|
1624
|
+
)
|
|
1625
|
+
|
|
1626
|
+
if is_btw:
|
|
1627
|
+
try:
|
|
1628
|
+
btw_input_row_id = await self._db.record_ephemeral_input(
|
|
1629
|
+
session_id, content, kind="btw"
|
|
1630
|
+
)
|
|
1631
|
+
if btw_input_row_id is not None:
|
|
1632
|
+
self._bus.publish(
|
|
1633
|
+
{
|
|
1634
|
+
"type": "ephemeral_input_persisted",
|
|
1635
|
+
"session_id": session_id,
|
|
1636
|
+
"input_id": btw_input_row_id,
|
|
1637
|
+
"kind": "btw",
|
|
1638
|
+
}
|
|
1639
|
+
)
|
|
1640
|
+
except Exception as exc:
|
|
1641
|
+
_log.warning(
|
|
1642
|
+
"pty: record_ephemeral_input failed (session %s): %s",
|
|
1643
|
+
session_id,
|
|
1644
|
+
exc,
|
|
1645
|
+
)
|
|
1646
|
+
# Proceed: losing the DB row is better than blocking the /btw
|
|
1647
|
+
# flow. btw_input_row_id stays None so finalize won't try
|
|
1648
|
+
# to persist the response row either.
|
|
1649
|
+
|
|
1650
|
+
# Phase 2 — /btw capture: arm the channel's accumulator
|
|
1651
|
+
# state now that we have the live channel reference and
|
|
1652
|
+
# hold the global lock (ensuring atomicity with the ring
|
|
1653
|
+
# drain). Clear the buffer first so back-to-back /btw
|
|
1654
|
+
# submits don't cross-contaminate.
|
|
1655
|
+
if is_btw:
|
|
1656
|
+
channel._state.expecting_btw_response = True
|
|
1657
|
+
channel._state.btw_input_row_id = btw_input_row_id
|
|
1658
|
+
channel._state.btw_buffer.clear()
|
|
1659
|
+
# Cancel any leftover finalize/timeout tasks from a
|
|
1660
|
+
# previous /btw on the same channel (shouldn't happen
|
|
1661
|
+
# in normal flows, but be safe).
|
|
1662
|
+
for task_attr in ("_btw_finalize_task", "_btw_timeout_task"):
|
|
1663
|
+
t = getattr(channel, task_attr)
|
|
1664
|
+
if t is not None and not t.done():
|
|
1665
|
+
t.cancel()
|
|
1666
|
+
setattr(channel, task_attr, None)
|
|
1667
|
+
# Schedule the stuck-modal timeout backstop.
|
|
1668
|
+
try:
|
|
1669
|
+
loop = asyncio.get_running_loop()
|
|
1670
|
+
channel._btw_timeout_task = loop.create_task(
|
|
1671
|
+
channel._btw_stuck_timeout()
|
|
1672
|
+
)
|
|
1673
|
+
_log.info(
|
|
1674
|
+
"pty: btw armed — input_row=%s, timeout_s=%.0f (session %s)",
|
|
1675
|
+
btw_input_row_id,
|
|
1676
|
+
channel._btw_stuck_timeout_s,
|
|
1677
|
+
session_id,
|
|
1678
|
+
)
|
|
1679
|
+
except RuntimeError: # pragma: no cover — loop closed
|
|
1680
|
+
_log.warning(
|
|
1681
|
+
"pty: btw arm — loop unavailable, no timeout task scheduled (session %s)",
|
|
1682
|
+
session_id,
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
# Inside the per-session lock, outside the global lock. A
|
|
1686
|
+
# slow os.write here serializes against other submits on
|
|
1687
|
+
# THIS sid (Phase-3 guarantee — no JSONL interleaving) but
|
|
1688
|
+
# leaves other sessions unblocked. The channel reference is
|
|
1689
|
+
# stable because shutdown() awaits the global lock before
|
|
1690
|
+
# tearing channels down.
|
|
1691
|
+
submit_ms = _now_ms()
|
|
1692
|
+
submit_output_seq = channel.last_pty_output_seq()
|
|
1693
|
+
channel.write(payload)
|
|
1694
|
+
# Kick off the input watchdog: emits pty_input_acknowledged
|
|
1695
|
+
# once the TUI starts producing output, or pty_input_stalled
|
|
1696
|
+
# if it doesn't react within ~5s.
|
|
1697
|
+
prior = self._input_watchdogs.pop(session_id, None)
|
|
1698
|
+
if prior is not None and not prior.done():
|
|
1699
|
+
prior.cancel()
|
|
1700
|
+
self._input_watchdogs[session_id] = asyncio.create_task(
|
|
1701
|
+
self._input_watchdog(
|
|
1702
|
+
session_id,
|
|
1703
|
+
submit_ms,
|
|
1704
|
+
submit_output_seq,
|
|
1705
|
+
submit_kind="slash" if is_slash else None,
|
|
1706
|
+
)
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
async def write_raw_input(self, session_id: str, data: bytes) -> None:
|
|
1710
|
+
"""Write raw terminal input bytes to the live PTY.
|
|
1711
|
+
|
|
1712
|
+
This is the Native View input path. It intentionally bypasses
|
|
1713
|
+
``_encode_submit`` so escape sequences, control characters, and
|
|
1714
|
+
partial terminal input reach Claude's TUI unchanged.
|
|
1715
|
+
"""
|
|
1716
|
+
async with self._session_lock(session_id):
|
|
1717
|
+
async with self._lock:
|
|
1718
|
+
if self._shutdown_started:
|
|
1719
|
+
raise RuntimeError("PtyManager is shutting down")
|
|
1720
|
+
managed = self._channels.get(session_id)
|
|
1721
|
+
if managed is None or not managed.channel.is_alive():
|
|
1722
|
+
raise RuntimeError(f"no live PTY channel for session {session_id}")
|
|
1723
|
+
channel = managed.channel
|
|
1724
|
+
self._reset_idle_timer_locked(session_id)
|
|
1725
|
+
|
|
1726
|
+
channel.write(data)
|
|
1727
|
+
|
|
1728
|
+
async def resize(self, session_id: str, rows: int, cols: int) -> None:
|
|
1729
|
+
"""Resize a live PTY channel for Native View."""
|
|
1730
|
+
async with self._session_lock(session_id):
|
|
1731
|
+
async with self._lock:
|
|
1732
|
+
if self._shutdown_started:
|
|
1733
|
+
raise RuntimeError("PtyManager is shutting down")
|
|
1734
|
+
managed = self._channels.get(session_id)
|
|
1735
|
+
if managed is None or not managed.channel.is_alive():
|
|
1736
|
+
raise RuntimeError(f"no live PTY channel for session {session_id}")
|
|
1737
|
+
channel = managed.channel
|
|
1738
|
+
self._reset_idle_timer_locked(session_id)
|
|
1739
|
+
|
|
1740
|
+
channel.resize(rows, cols)
|
|
1741
|
+
|
|
1742
|
+
def native_snapshot(self, session_id: str) -> dict[str, Any]:
|
|
1743
|
+
"""Return Native View hydration state for a live PTY channel."""
|
|
1744
|
+
managed = self._channels.get(session_id)
|
|
1745
|
+
if managed is None:
|
|
1746
|
+
raise RuntimeError(f"no PTY channel for session {session_id}")
|
|
1747
|
+
self._set_active_session_locked(session_id)
|
|
1748
|
+
ch = managed.channel
|
|
1749
|
+
rows, cols = ch.dimensions()
|
|
1750
|
+
alive = ch.is_alive()
|
|
1751
|
+
if alive:
|
|
1752
|
+
screen_text = ch.output_snapshot().decode("utf-8", errors="replace")
|
|
1753
|
+
classification = classify_screen(screen_text)
|
|
1754
|
+
native_state = classification.state
|
|
1755
|
+
decoded_input_safe = classification.decoded_input_safe
|
|
1756
|
+
else:
|
|
1757
|
+
native_state = "dead"
|
|
1758
|
+
decoded_input_safe = False
|
|
1759
|
+
return encode_pty_snapshot(
|
|
1760
|
+
session_id=session_id,
|
|
1761
|
+
ring=ch.output_snapshot(),
|
|
1762
|
+
ring_complete=ch.output_snapshot_complete(),
|
|
1763
|
+
rows=rows,
|
|
1764
|
+
cols=cols,
|
|
1765
|
+
alive=alive,
|
|
1766
|
+
native_state=native_state,
|
|
1767
|
+
decoded_input_safe=decoded_input_safe,
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
def _flush_output(self, channel: PtyChannel) -> None:
|
|
1771
|
+
"""Emit the coalesced output for a channel (scheduled by the on_chunk
|
|
1772
|
+
hook). Publishes ONE combined ``pty_output_chunk`` for the active session
|
|
1773
|
+
plus, at most, ONE ``pty_native_state`` — and only when the classified
|
|
1774
|
+
state actually changed (it stays constant through a scroll, so this drops
|
|
1775
|
+
nearly all of the per-chunk state firehose). Runs on the loop; must not
|
|
1776
|
+
raise.
|
|
1777
|
+
|
|
1778
|
+
Takes the live ``channel`` directly rather than re-looking it up in
|
|
1779
|
+
``_channels``: output can arrive during spawn/bootstrap BEFORE focus()
|
|
1780
|
+
registers the channel, and a ``_channels`` miss would silently drop that
|
|
1781
|
+
first state/output. The on_chunk hook always has a valid channel ref.
|
|
1782
|
+
"""
|
|
1783
|
+
session_id = channel.session_id
|
|
1784
|
+
self._output_flush_handles.pop(session_id, None)
|
|
1785
|
+
pending = self._pending_output.pop(session_id, None)
|
|
1786
|
+
|
|
1787
|
+
if pending and session_id == self._active_session_id:
|
|
1788
|
+
try:
|
|
1789
|
+
self._bus.publish(encode_pty_output_chunk(session_id, bytes(pending)))
|
|
1790
|
+
except Exception as exc:
|
|
1791
|
+
_log.warning(
|
|
1792
|
+
"pty: output chunk publish raised (session %s): %s",
|
|
1793
|
+
session_id,
|
|
1794
|
+
exc,
|
|
1795
|
+
)
|
|
1796
|
+
|
|
1797
|
+
try:
|
|
1798
|
+
# Classify only the TAIL of the ring, never the whole thing. The
|
|
1799
|
+
# state we detect (prompts, trust/login banners) is always on the
|
|
1800
|
+
# CURRENT screen, which lives in the last screenful of output — 4 MB
|
|
1801
|
+
# of scrollback history is both irrelevant and dangerous to classify
|
|
1802
|
+
# (stale matches), and classify_screen → _normalize runs three regex
|
|
1803
|
+
# passes plus a char-by-char comprehension over its input, so feeding
|
|
1804
|
+
# it the whole ring is O(session length) work. Capping at
|
|
1805
|
+
# _CLASSIFY_TAIL_BYTES keeps each call cheap and constant-time.
|
|
1806
|
+
ring = channel._state.ring
|
|
1807
|
+
tail = (
|
|
1808
|
+
ring[-_CLASSIFY_TAIL_BYTES:]
|
|
1809
|
+
if len(ring) > _CLASSIFY_TAIL_BYTES
|
|
1810
|
+
else ring
|
|
1811
|
+
)
|
|
1812
|
+
classification = classify_screen(
|
|
1813
|
+
bytes(tail).decode("utf-8", errors="replace")
|
|
1814
|
+
)
|
|
1815
|
+
key = (classification.state, classification.decoded_input_safe)
|
|
1816
|
+
if self._last_native_state.get(session_id) != key:
|
|
1817
|
+
self._last_native_state[session_id] = key
|
|
1818
|
+
self._bus.publish(
|
|
1819
|
+
{
|
|
1820
|
+
"type": "pty_native_state",
|
|
1821
|
+
"session_id": session_id,
|
|
1822
|
+
"state": classification.state,
|
|
1823
|
+
"decoded_input_safe": classification.decoded_input_safe,
|
|
1824
|
+
}
|
|
1825
|
+
)
|
|
1826
|
+
except Exception as exc:
|
|
1827
|
+
_log.warning(
|
|
1828
|
+
"pty: native state publish raised (session %s): %s",
|
|
1829
|
+
session_id,
|
|
1830
|
+
exc,
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
def _clear_output_coalesce(self, session_id: str) -> None:
|
|
1834
|
+
"""Drop a session's output-coalescing state (cancel any pending flush).
|
|
1835
|
+
Called when a channel is removed so timers and buffers don't linger."""
|
|
1836
|
+
handle = self._output_flush_handles.pop(session_id, None)
|
|
1837
|
+
if handle is not None:
|
|
1838
|
+
handle.cancel()
|
|
1839
|
+
self._pending_output.pop(session_id, None)
|
|
1840
|
+
self._last_native_state.pop(session_id, None)
|
|
1841
|
+
|
|
1842
|
+
def _scan_chunk_for_hitl(self, channel: PtyChannel, chunk: bytes) -> None:
|
|
1843
|
+
"""PtyChannel ``on_chunk`` hook — scans drained bytes for HITL signals.
|
|
1844
|
+
|
|
1845
|
+
Currently watches for the ``Not logged in`` banner that ``claude``
|
|
1846
|
+
prints when the user's subscription / auth has lapsed. Emits the
|
|
1847
|
+
``auth_required`` SSE event once per channel lifetime (a respawn
|
|
1848
|
+
gets a fresh channel and may re-emit) and matches the payload
|
|
1849
|
+
shape the frontend expects (``frontend/src/api/client.ts`` ~L319).
|
|
1850
|
+
|
|
1851
|
+
The check straddles ring boundaries: we concatenate the tail of
|
|
1852
|
+
the ring (already written by the caller) with the new chunk only
|
|
1853
|
+
when the chunk alone doesn't contain the marker — handles the
|
|
1854
|
+
rare case where the pattern is split across two read() calls.
|
|
1855
|
+
|
|
1856
|
+
Sync (runs inside the asyncio reader callback). Must not block.
|
|
1857
|
+
"""
|
|
1858
|
+
# Coalesce the output broadcast AND the state reclassification onto a
|
|
1859
|
+
# short timer instead of doing them per raw read. claude writes a single
|
|
1860
|
+
# TUI repaint as many tiny (~1 KB) PTY writes, each firing add_reader
|
|
1861
|
+
# separately; publishing one SSE event + reclassifying per read turned a
|
|
1862
|
+
# single scroll flick into ~360 SSE events and ~180 frontend repaints
|
|
1863
|
+
# (measured). We accumulate raw reads per session and flush ONE combined
|
|
1864
|
+
# output chunk (+ at most one reclassification) every _OUTPUT_COALESCE_S
|
|
1865
|
+
# — so the browser does ~one xterm.write per frame. Faithful: the bytes
|
|
1866
|
+
# are delivered in order, only batched.
|
|
1867
|
+
self._pending_output.setdefault(channel.session_id, bytearray()).extend(chunk)
|
|
1868
|
+
if channel.session_id not in self._output_flush_handles:
|
|
1869
|
+
try:
|
|
1870
|
+
loop = asyncio.get_running_loop()
|
|
1871
|
+
self._output_flush_handles[channel.session_id] = loop.call_later(
|
|
1872
|
+
_OUTPUT_COALESCE_S, self._flush_output, channel
|
|
1873
|
+
)
|
|
1874
|
+
except RuntimeError:
|
|
1875
|
+
# No running loop (should not happen in the reader callback) —
|
|
1876
|
+
# flush inline so output is never stranded.
|
|
1877
|
+
self._flush_output(channel)
|
|
1878
|
+
|
|
1879
|
+
state = channel._state
|
|
1880
|
+
if state.auth_required_emitted:
|
|
1881
|
+
return
|
|
1882
|
+
if _AUTH_REQUIRED_PATTERN in chunk:
|
|
1883
|
+
hit = True
|
|
1884
|
+
else:
|
|
1885
|
+
# The ring already contains ``chunk`` (caller appended before
|
|
1886
|
+
# invoking us). Inspect the trailing window covering the
|
|
1887
|
+
# boundary between the previous chunk and this one.
|
|
1888
|
+
ring = state.ring
|
|
1889
|
+
window_start = max(0, len(ring) - len(chunk) - _AUTH_REQUIRED_CARRYOVER)
|
|
1890
|
+
hit = _AUTH_REQUIRED_PATTERN in ring[window_start:]
|
|
1891
|
+
if not hit:
|
|
1892
|
+
return
|
|
1893
|
+
state.auth_required_emitted = True
|
|
1894
|
+
try:
|
|
1895
|
+
self._bus.publish(
|
|
1896
|
+
{
|
|
1897
|
+
"type": "auth_required",
|
|
1898
|
+
"session_id": channel.session_id,
|
|
1899
|
+
}
|
|
1900
|
+
)
|
|
1901
|
+
except Exception as exc:
|
|
1902
|
+
_log.warning(
|
|
1903
|
+
"pty: auth_required publish raised (session %s): %s",
|
|
1904
|
+
channel.session_id,
|
|
1905
|
+
exc,
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
def _publish_native_dead_state(self, channel: PtyChannel) -> None:
|
|
1909
|
+
"""PtyChannel ``on_dead`` hook — publish terminal Native View state."""
|
|
1910
|
+
try:
|
|
1911
|
+
self._bus.publish(
|
|
1912
|
+
{
|
|
1913
|
+
"type": "pty_native_state",
|
|
1914
|
+
"session_id": channel.session_id,
|
|
1915
|
+
"state": "dead",
|
|
1916
|
+
"decoded_input_safe": False,
|
|
1917
|
+
}
|
|
1918
|
+
)
|
|
1919
|
+
except Exception as exc:
|
|
1920
|
+
_log.warning(
|
|
1921
|
+
"pty: native dead-state publish raised (session %s): %s",
|
|
1922
|
+
channel.session_id,
|
|
1923
|
+
exc,
|
|
1924
|
+
)
|
|
1925
|
+
|
|
1926
|
+
async def _input_watchdog(
|
|
1927
|
+
self,
|
|
1928
|
+
session_id: str,
|
|
1929
|
+
submit_ms: int,
|
|
1930
|
+
submit_output_seq: int,
|
|
1931
|
+
*,
|
|
1932
|
+
submit_kind: Optional[str] = None,
|
|
1933
|
+
ack_after_s: float = 0.5,
|
|
1934
|
+
stall_after_s: float = 5.0,
|
|
1935
|
+
) -> None:
|
|
1936
|
+
"""Background task: emit pty_input_acknowledged / pty_input_stalled.
|
|
1937
|
+
|
|
1938
|
+
Fires once per submit, lives at most ``stall_after_s`` seconds.
|
|
1939
|
+
Cancelled by a subsequent submit on the same session — the
|
|
1940
|
+
latest user input is the one we report on.
|
|
1941
|
+
"""
|
|
1942
|
+
try:
|
|
1943
|
+
await asyncio.sleep(ack_after_s)
|
|
1944
|
+
managed = self._channels.get(session_id)
|
|
1945
|
+
if managed is None:
|
|
1946
|
+
return
|
|
1947
|
+
if managed.channel.last_pty_output_seq() > submit_output_seq:
|
|
1948
|
+
self._bus.publish(
|
|
1949
|
+
{
|
|
1950
|
+
"type": "pty_input_acknowledged",
|
|
1951
|
+
"session_id": session_id,
|
|
1952
|
+
}
|
|
1953
|
+
)
|
|
1954
|
+
if submit_kind == "slash":
|
|
1955
|
+
managed.channel._publish_submit_completed(
|
|
1956
|
+
kind="slash",
|
|
1957
|
+
status="acknowledged",
|
|
1958
|
+
input_row_id=None,
|
|
1959
|
+
response_row_id=None,
|
|
1960
|
+
)
|
|
1961
|
+
return
|
|
1962
|
+
# No acknowledgement yet — wait the rest of the stall window.
|
|
1963
|
+
await asyncio.sleep(stall_after_s - ack_after_s)
|
|
1964
|
+
managed = self._channels.get(session_id)
|
|
1965
|
+
if managed is None:
|
|
1966
|
+
return
|
|
1967
|
+
if managed.channel.last_pty_output_seq() <= submit_output_seq:
|
|
1968
|
+
self._bus.publish(
|
|
1969
|
+
{
|
|
1970
|
+
"type": "pty_input_stalled",
|
|
1971
|
+
"session_id": session_id,
|
|
1972
|
+
"elapsed_ms": _now_ms() - submit_ms,
|
|
1973
|
+
}
|
|
1974
|
+
)
|
|
1975
|
+
except asyncio.CancelledError:
|
|
1976
|
+
# Newer submit superseded this watchdog — silent exit.
|
|
1977
|
+
pass
|
|
1978
|
+
except Exception as exc: # never raise from a background task
|
|
1979
|
+
_log.warning(
|
|
1980
|
+
"pty: input watchdog crashed (session %s): %s",
|
|
1981
|
+
session_id,
|
|
1982
|
+
exc,
|
|
1983
|
+
)
|
|
1984
|
+
|
|
1985
|
+
async def switch_model(
|
|
1986
|
+
self,
|
|
1987
|
+
session_id: str,
|
|
1988
|
+
model: str,
|
|
1989
|
+
*,
|
|
1990
|
+
settle_s: float = 0.3,
|
|
1991
|
+
) -> bool:
|
|
1992
|
+
"""Switch the live TUI's model via claude's ``/model`` slash command.
|
|
1993
|
+
|
|
1994
|
+
Keeps the TUI alive — no kill, no respawn, no JSONL re-read. The slash
|
|
1995
|
+
command produces a ``user`` record in JSONL containing
|
|
1996
|
+
``<command-name>/model</command-name>`` plus a ``local-command-stdout``
|
|
1997
|
+
confirmation ("Set model to <Name>"); subsequent turns use the new
|
|
1998
|
+
model. Verified end-to-end against ``crad`` v2.1.143.
|
|
1999
|
+
|
|
2000
|
+
Returns True if the command was written. False if no live channel
|
|
2001
|
+
exists (caller should fall through to the auto-focus path).
|
|
2002
|
+
|
|
2003
|
+
``settle_s`` is a brief wait that lets the TUI process the slash
|
|
2004
|
+
command before the next user message is written. 300ms is enough in
|
|
2005
|
+
practice; we don't poll for a specific marker because the slash
|
|
2006
|
+
command's local-stdout shows up in JSONL only AFTER the TUI flushes,
|
|
2007
|
+
which can race with concurrent reads.
|
|
2008
|
+
"""
|
|
2009
|
+
async with self._session_lock(session_id):
|
|
2010
|
+
async with self._lock:
|
|
2011
|
+
if self._shutdown_started:
|
|
2012
|
+
raise RuntimeError("PtyManager is shutting down")
|
|
2013
|
+
managed = self._channels.get(session_id)
|
|
2014
|
+
if managed is None or not managed.channel.is_alive():
|
|
2015
|
+
return False
|
|
2016
|
+
channel = managed.channel
|
|
2017
|
+
# Update cached focus params so status() and any future
|
|
2018
|
+
# respawn (e.g. after an idle-kill) reflect the new
|
|
2019
|
+
# model.
|
|
2020
|
+
new_params = replace(managed.focus_params, model=model)
|
|
2021
|
+
managed.focus_params = new_params
|
|
2022
|
+
self._last_focus[session_id] = new_params
|
|
2023
|
+
|
|
2024
|
+
# Inside the per-session lock so the /model write can't
|
|
2025
|
+
# interleave with a concurrent submit() on the same sid.
|
|
2026
|
+
channel.write(f"/model {model}\r".encode("utf-8"))
|
|
2027
|
+
if settle_s > 0:
|
|
2028
|
+
await asyncio.sleep(settle_s)
|
|
2029
|
+
return True
|
|
2030
|
+
|
|
2031
|
+
async def kill(self, session_id: str) -> None:
|
|
2032
|
+
"""Tear down the channel for ``session_id`` (debug endpoint).
|
|
2033
|
+
|
|
2034
|
+
Also drops the cached focus params: an explicit kill from the
|
|
2035
|
+
UI/debugger is "forget this session" — a follow-up submit MUST
|
|
2036
|
+
re-focus before sending. Compare with idle-kill, which keeps
|
|
2037
|
+
the cache so auto-respawn just works.
|
|
2038
|
+
"""
|
|
2039
|
+
async with self._session_lock(session_id):
|
|
2040
|
+
async with self._lock:
|
|
2041
|
+
managed = self._channels.pop(session_id, None)
|
|
2042
|
+
self._last_focus.pop(session_id, None)
|
|
2043
|
+
self._clear_output_coalesce(session_id)
|
|
2044
|
+
self._cancel_idle_timers(managed)
|
|
2045
|
+
if managed is not None:
|
|
2046
|
+
await managed.channel.kill()
|
|
2047
|
+
|
|
2048
|
+
def status(self, session_id: str) -> dict:
|
|
2049
|
+
"""Snapshot the channel's liveness + timer state.
|
|
2050
|
+
|
|
2051
|
+
Reads under no lock. The returned values are copied scalars, and
|
|
2052
|
+
the worst case is observing a channel entry that has just been
|
|
2053
|
+
removed from the map. The server's ``/api/pty/status`` consumer
|
|
2054
|
+
is read-only and idempotent on stale answers.
|
|
2055
|
+
"""
|
|
2056
|
+
managed = self._channels.get(session_id)
|
|
2057
|
+
if managed is None:
|
|
2058
|
+
return {
|
|
2059
|
+
"alive": False,
|
|
2060
|
+
"last_activity_ms": 0,
|
|
2061
|
+
"last_input_ms": 0,
|
|
2062
|
+
"last_pty_output_ms": 0,
|
|
2063
|
+
"idle_warn_at_ms": 0,
|
|
2064
|
+
"idle_kill_at_ms": 0,
|
|
2065
|
+
"model": "",
|
|
2066
|
+
"permission_mode": "",
|
|
2067
|
+
}
|
|
2068
|
+
ch = managed.channel
|
|
2069
|
+
return {
|
|
2070
|
+
"alive": ch.is_alive(),
|
|
2071
|
+
"last_activity_ms": ch.last_activity_ms(),
|
|
2072
|
+
"last_input_ms": ch.last_input_ms(),
|
|
2073
|
+
"last_pty_output_ms": ch.last_pty_output_ms(),
|
|
2074
|
+
"idle_warn_at_ms": managed.idle_warn_at_ms,
|
|
2075
|
+
"idle_kill_at_ms": managed.idle_kill_at_ms,
|
|
2076
|
+
# Expose the current channel model + permission mode so callers
|
|
2077
|
+
# can detect picker drift and request a safe switch on submit.
|
|
2078
|
+
"model": managed.focus_params.model,
|
|
2079
|
+
"permission_mode": managed.focus_params.permission_mode,
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
def ownership(
|
|
2083
|
+
self,
|
|
2084
|
+
session_id: str,
|
|
2085
|
+
jsonl_path: Optional[Path],
|
|
2086
|
+
) -> dict:
|
|
2087
|
+
"""Report who owns ``session_id``'s JSONL, without spawning.
|
|
2088
|
+
|
|
2089
|
+
Returns:
|
|
2090
|
+
- ``status="ours"`` — a live PtyChannel for this sid exists here.
|
|
2091
|
+
- ``status="terminal"`` — another process holds the JSONL open.
|
|
2092
|
+
- ``status="idle"`` — no one (we don't have a channel and no
|
|
2093
|
+
foreign signal fires).
|
|
2094
|
+
|
|
2095
|
+
Used by ``GET /api/pty/ownership/{sid}`` to drive the FE badge
|
|
2096
|
+
without triggering ``focus()`` (which would spawn or refuse).
|
|
2097
|
+
Returns a ``foreign_owner`` dict when a Phase-1 lock sidecar
|
|
2098
|
+
names the foreign owner (kind / pid / hostname / ui_endpoint /
|
|
2099
|
+
heartbeat_at) so the FE tooltip can render structured detail
|
|
2100
|
+
instead of just a pid number.
|
|
2101
|
+
"""
|
|
2102
|
+
pids = _session_conflict_pids(session_id, jsonl_path)
|
|
2103
|
+
sc = _fresh_foreign_sidecar(jsonl_path)
|
|
2104
|
+
foreign_owner: Optional[dict] = None
|
|
2105
|
+
if sc is not None:
|
|
2106
|
+
foreign_owner = {
|
|
2107
|
+
"kind": sc.owner_kind,
|
|
2108
|
+
"pid": sc.pid,
|
|
2109
|
+
"hostname": sc.hostname,
|
|
2110
|
+
"ui_endpoint": sc.ui_endpoint,
|
|
2111
|
+
"heartbeat_at": sc.heartbeat_at.isoformat(),
|
|
2112
|
+
}
|
|
2113
|
+
managed = self._channels.get(session_id)
|
|
2114
|
+
if (
|
|
2115
|
+
managed is not None
|
|
2116
|
+
and managed.channel.is_alive()
|
|
2117
|
+
and not pids
|
|
2118
|
+
and foreign_owner is None
|
|
2119
|
+
):
|
|
2120
|
+
return {
|
|
2121
|
+
"status": "ours",
|
|
2122
|
+
"foreign_pids": [],
|
|
2123
|
+
"foreign_owner": None,
|
|
2124
|
+
"jsonl_path": str(jsonl_path) if jsonl_path else None,
|
|
2125
|
+
}
|
|
2126
|
+
return {
|
|
2127
|
+
"status": "terminal" if (pids or foreign_owner is not None) else "idle",
|
|
2128
|
+
"foreign_pids": pids,
|
|
2129
|
+
"foreign_owner": foreign_owner,
|
|
2130
|
+
"jsonl_path": str(jsonl_path) if jsonl_path else None,
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
async def shutdown(self) -> None:
|
|
2134
|
+
"""Tear down every live channel. Idempotent."""
|
|
2135
|
+
async with self._lock:
|
|
2136
|
+
if self._shutdown_started:
|
|
2137
|
+
return
|
|
2138
|
+
self._shutdown_started = True
|
|
2139
|
+
items = list(self._channels.items())
|
|
2140
|
+
self._channels.clear()
|
|
2141
|
+
self._last_focus.clear()
|
|
2142
|
+
for _, managed in items:
|
|
2143
|
+
self._cancel_idle_timers(managed)
|
|
2144
|
+
# Cancel pending input watchdogs so shutdown doesn't leak tasks.
|
|
2145
|
+
for task in self._input_watchdogs.values():
|
|
2146
|
+
if not task.done():
|
|
2147
|
+
task.cancel()
|
|
2148
|
+
self._input_watchdogs.clear()
|
|
2149
|
+
|
|
2150
|
+
# Cancel pending /btw capture tasks on all channels.
|
|
2151
|
+
for _, managed in items:
|
|
2152
|
+
ch = managed.channel
|
|
2153
|
+
for task_attr in ("_btw_finalize_task", "_btw_timeout_task"):
|
|
2154
|
+
t = getattr(ch, task_attr, None)
|
|
2155
|
+
if t is not None and not t.done():
|
|
2156
|
+
t.cancel()
|
|
2157
|
+
|
|
2158
|
+
for session_id, managed in items:
|
|
2159
|
+
try:
|
|
2160
|
+
await managed.channel.kill()
|
|
2161
|
+
except Exception as exc:
|
|
2162
|
+
_log.warning(
|
|
2163
|
+
"pty: shutdown kill raised (session %s): %s",
|
|
2164
|
+
session_id,
|
|
2165
|
+
exc,
|
|
2166
|
+
)
|
|
2167
|
+
|
|
2168
|
+
# ------------------------------------------------------------------
|
|
2169
|
+
# Internals
|
|
2170
|
+
# ------------------------------------------------------------------
|
|
2171
|
+
|
|
2172
|
+
async def _ensure_channel_locked(
|
|
2173
|
+
self,
|
|
2174
|
+
session_id: str,
|
|
2175
|
+
params: _FocusParams,
|
|
2176
|
+
) -> _ManagedChannel:
|
|
2177
|
+
"""Spawn a channel if none alive. Caller must hold ``self._lock``."""
|
|
2178
|
+
managed = self._channels.get(session_id)
|
|
2179
|
+
if managed is not None and managed.channel.is_alive():
|
|
2180
|
+
return managed
|
|
2181
|
+
|
|
2182
|
+
# Replace any dead channel record before spawning a new one.
|
|
2183
|
+
if managed is not None:
|
|
2184
|
+
self._cancel_idle_timers(managed)
|
|
2185
|
+
# Best-effort cleanup of the dead channel's fds — kill() is
|
|
2186
|
+
# idempotent and safe to call after the drain has already
|
|
2187
|
+
# marked it dead.
|
|
2188
|
+
try:
|
|
2189
|
+
await managed.channel.kill()
|
|
2190
|
+
except Exception as exc:
|
|
2191
|
+
_log.warning(
|
|
2192
|
+
"pty: cleanup of dead channel raised (session %s): %s",
|
|
2193
|
+
session_id,
|
|
2194
|
+
exc,
|
|
2195
|
+
)
|
|
2196
|
+
self._channels.pop(session_id, None)
|
|
2197
|
+
self._clear_output_coalesce(session_id)
|
|
2198
|
+
|
|
2199
|
+
argv = _build_argv(
|
|
2200
|
+
bin_name=params.bin_name,
|
|
2201
|
+
session_id=session_id,
|
|
2202
|
+
model=params.model,
|
|
2203
|
+
permission_mode=params.permission_mode,
|
|
2204
|
+
new_chat=params.new_chat,
|
|
2205
|
+
)
|
|
2206
|
+
env = await _pty_env(params.rows, self._native_cols, params.bin_name)
|
|
2207
|
+
channel = PtyChannel(
|
|
2208
|
+
session_id=session_id,
|
|
2209
|
+
argv=argv,
|
|
2210
|
+
cwd=params.cwd,
|
|
2211
|
+
env=env,
|
|
2212
|
+
rows=params.rows,
|
|
2213
|
+
cols=self._native_cols,
|
|
2214
|
+
on_chunk=self._scan_chunk_for_hitl,
|
|
2215
|
+
on_dead=self._publish_native_dead_state,
|
|
2216
|
+
jsonl_path=params.jsonl_path,
|
|
2217
|
+
ui_endpoint=self._ui_endpoint,
|
|
2218
|
+
db=self._db,
|
|
2219
|
+
bus=self._bus,
|
|
2220
|
+
)
|
|
2221
|
+
try:
|
|
2222
|
+
await channel.start()
|
|
2223
|
+
except LockAlreadyHeld as exc:
|
|
2224
|
+
# Lock race lost between focus()'s pre-spawn detector and
|
|
2225
|
+
# the actual acquire. Promote to PtyOwnershipConflict so the
|
|
2226
|
+
# HTTP layer returns 409 with the foreign pid.
|
|
2227
|
+
sc = exc.existing
|
|
2228
|
+
foreign = [sc.pid] if sc.hostname == socket.gethostname() else []
|
|
2229
|
+
_log.info(
|
|
2230
|
+
"pty: lock race lost (session %s) — held by %s@%s pid %d",
|
|
2231
|
+
session_id,
|
|
2232
|
+
sc.owner_kind,
|
|
2233
|
+
sc.hostname,
|
|
2234
|
+
sc.pid,
|
|
2235
|
+
)
|
|
2236
|
+
raise PtyOwnershipConflict(foreign)
|
|
2237
|
+
except Exception as exc:
|
|
2238
|
+
_log.warning(
|
|
2239
|
+
"pty: spawn failed (session %s, argv=%s): %s",
|
|
2240
|
+
session_id,
|
|
2241
|
+
" ".join(argv),
|
|
2242
|
+
exc,
|
|
2243
|
+
)
|
|
2244
|
+
raise
|
|
2245
|
+
|
|
2246
|
+
# Wait for the TUI to mount its input handler before letting the
|
|
2247
|
+
# caller proceed. Without this, a submit that piggy-backs onto the
|
|
2248
|
+
# focus call (e.g. model-switch respawn during ``pty_submit``)
|
|
2249
|
+
# writes bytes into a bootstrapping TUI and they get silently
|
|
2250
|
+
# dropped. Phase 0 verified the bracketed-paste enable escape
|
|
2251
|
+
# ``\\x1b[?2004h`` reliably signals "ready" for claude v2.1.143.
|
|
2252
|
+
ready = await channel.await_ready(timeout_s=5.0)
|
|
2253
|
+
if not ready:
|
|
2254
|
+
_log.warning(
|
|
2255
|
+
"pty: TUI did not signal ready within 5s (session %s); "
|
|
2256
|
+
"proceeding anyway — first write may race bootstrap",
|
|
2257
|
+
session_id,
|
|
2258
|
+
)
|
|
2259
|
+
|
|
2260
|
+
managed = _ManagedChannel(channel=channel, focus_params=params)
|
|
2261
|
+
self._channels[session_id] = managed
|
|
2262
|
+
return managed
|
|
2263
|
+
|
|
2264
|
+
def _encode_submit(self, content: str) -> bytes:
|
|
2265
|
+
"""Translate a content string into PTY-submit bytes.
|
|
2266
|
+
|
|
2267
|
+
In-message newlines become Ctrl+J (0x0a); a trailing CR (0x0d)
|
|
2268
|
+
terminates the message per Phase 0. UTF-8 errors are tolerated
|
|
2269
|
+
because the input has already gone through JSON encode/decode at
|
|
2270
|
+
the HTTP layer.
|
|
2271
|
+
"""
|
|
2272
|
+
# In-message newlines submit as Ctrl+J (0x0a). Python's str
|
|
2273
|
+
# already encodes "\n" as 0x0a in UTF-8, so direct encoding is
|
|
2274
|
+
# equivalent — but we surface the intent explicitly via the
|
|
2275
|
+
# _INLINE_NEWLINE constant so the Phase 0 contract is greppable.
|
|
2276
|
+
body = content.encode("utf-8", errors="replace").replace(b"\n", _INLINE_NEWLINE)
|
|
2277
|
+
# Strip any user-provided trailing CR/LF so we don't double-submit.
|
|
2278
|
+
body = body.rstrip(b"\r\n")
|
|
2279
|
+
return body + _SUBMIT_BYTE
|
|
2280
|
+
|
|
2281
|
+
# --- idle timer ---------------------------------------------------
|
|
2282
|
+
|
|
2283
|
+
def _reset_idle_timer_locked(self, session_id: str) -> None:
|
|
2284
|
+
"""(Re)schedule warn + kill callbacks for ``session_id``.
|
|
2285
|
+
|
|
2286
|
+
Caller must hold ``self._lock``. Cancelling a TimerHandle is
|
|
2287
|
+
cheap and safe even if it already fired, so we don't guard on
|
|
2288
|
+
existence.
|
|
2289
|
+
"""
|
|
2290
|
+
managed = self._channels.get(session_id)
|
|
2291
|
+
if managed is None:
|
|
2292
|
+
return
|
|
2293
|
+
self._cancel_idle_timers(managed)
|
|
2294
|
+
|
|
2295
|
+
loop = asyncio.get_running_loop()
|
|
2296
|
+
now_ms = _now_ms()
|
|
2297
|
+
managed.idle_warn_at_ms = now_ms + int(self._idle_warn_s * 1000)
|
|
2298
|
+
managed.idle_kill_at_ms = now_ms + int(self._idle_timeout_s * 1000)
|
|
2299
|
+
managed.idle_warn_handle = loop.call_later(
|
|
2300
|
+
self._idle_warn_s, self._on_idle_warn, session_id
|
|
2301
|
+
)
|
|
2302
|
+
managed.idle_kill_handle = loop.call_later(
|
|
2303
|
+
self._idle_timeout_s, self._on_idle_kill, session_id
|
|
2304
|
+
)
|
|
2305
|
+
|
|
2306
|
+
def _cancel_idle_timers(self, managed: Optional[_ManagedChannel]) -> None:
|
|
2307
|
+
if managed is None:
|
|
2308
|
+
return
|
|
2309
|
+
for handle_attr in ("idle_warn_handle", "idle_kill_handle"):
|
|
2310
|
+
handle = getattr(managed, handle_attr)
|
|
2311
|
+
if handle is not None:
|
|
2312
|
+
try:
|
|
2313
|
+
handle.cancel()
|
|
2314
|
+
except Exception:
|
|
2315
|
+
pass
|
|
2316
|
+
setattr(managed, handle_attr, None)
|
|
2317
|
+
|
|
2318
|
+
def _on_idle_warn(self, session_id: str) -> None:
|
|
2319
|
+
"""Fired at T+idle_warn_s. SSE-only; doesn't touch the channel."""
|
|
2320
|
+
managed = self._channels.get(session_id)
|
|
2321
|
+
if managed is None or not managed.channel.is_alive():
|
|
2322
|
+
return
|
|
2323
|
+
kill_in = int(self._idle_timeout_s - self._idle_warn_s)
|
|
2324
|
+
event: dict[str, Any] = {
|
|
2325
|
+
"type": "pty_idle_warn",
|
|
2326
|
+
"session_id": session_id,
|
|
2327
|
+
"kill_in_seconds": kill_in,
|
|
2328
|
+
}
|
|
2329
|
+
try:
|
|
2330
|
+
self._bus.publish(event)
|
|
2331
|
+
except Exception as exc:
|
|
2332
|
+
_log.warning(
|
|
2333
|
+
"pty: idle_warn publish raised (session %s): %s",
|
|
2334
|
+
session_id,
|
|
2335
|
+
exc,
|
|
2336
|
+
)
|
|
2337
|
+
|
|
2338
|
+
def _on_idle_kill(self, session_id: str) -> None:
|
|
2339
|
+
"""Fired at T+idle_timeout_s. Schedules the actual kill coroutine.
|
|
2340
|
+
|
|
2341
|
+
``loop.call_later`` callbacks are synchronous, so we hand the
|
|
2342
|
+
async kill off via ``create_task``. We pop the channel here
|
|
2343
|
+
synchronously so a follow-up ``submit()`` sees no live channel
|
|
2344
|
+
and triggers auto-respawn via ``focus()``.
|
|
2345
|
+
|
|
2346
|
+
Phase 2: if a /btw capture is mid-flight
|
|
2347
|
+
(``expecting_btw_response=True``), defer the idle-kill by one
|
|
2348
|
+
full ``idle_timeout_s`` window. Killing now would destroy the
|
|
2349
|
+
``btw_buffer`` + the pending ``_btw_timeout_task`` before the
|
|
2350
|
+
180 s stuck-modal backstop could persist whatever was captured
|
|
2351
|
+
— exactly the Phase 2 live-smoke failure mode. The backstop
|
|
2352
|
+
will reset ``expecting_btw_response`` itself, so a subsequent
|
|
2353
|
+
firing will proceed normally.
|
|
2354
|
+
"""
|
|
2355
|
+
managed = self._channels.get(session_id)
|
|
2356
|
+
if managed is None:
|
|
2357
|
+
return
|
|
2358
|
+
# The session currently on screen (being viewed) is never idle-killed —
|
|
2359
|
+
# re-arm a fresh lease instead. It only becomes kill-eligible once the
|
|
2360
|
+
# user navigates away: unfocus() clears _active_session_id and shortens
|
|
2361
|
+
# the timer to the blurred window, so the next firing has
|
|
2362
|
+
# session_id != _active_session_id and proceeds with the kill.
|
|
2363
|
+
if session_id == self._active_session_id:
|
|
2364
|
+
try:
|
|
2365
|
+
loop = asyncio.get_running_loop()
|
|
2366
|
+
managed.idle_kill_handle = loop.call_later(
|
|
2367
|
+
self._idle_timeout_s, self._on_idle_kill, session_id
|
|
2368
|
+
)
|
|
2369
|
+
except RuntimeError:
|
|
2370
|
+
pass
|
|
2371
|
+
return
|
|
2372
|
+
if managed.channel._state.expecting_btw_response:
|
|
2373
|
+
_log.info(
|
|
2374
|
+
"pty: idle-kill deferred — /btw capture in flight (session %s)",
|
|
2375
|
+
session_id,
|
|
2376
|
+
)
|
|
2377
|
+
try:
|
|
2378
|
+
loop = asyncio.get_running_loop()
|
|
2379
|
+
managed.idle_kill_handle = loop.call_later(
|
|
2380
|
+
self._idle_timeout_s, self._on_idle_kill, session_id
|
|
2381
|
+
)
|
|
2382
|
+
except RuntimeError:
|
|
2383
|
+
pass
|
|
2384
|
+
return
|
|
2385
|
+
# No /btw in flight — proceed with the actual kill.
|
|
2386
|
+
self._channels.pop(session_id, None)
|
|
2387
|
+
self._clear_output_coalesce(session_id)
|
|
2388
|
+
self._cancel_idle_timers(managed)
|
|
2389
|
+
_log.info("pty: idle-killing (session %s)", session_id)
|
|
2390
|
+
|
|
2391
|
+
async def _kill_then_log() -> None:
|
|
2392
|
+
try:
|
|
2393
|
+
await managed.channel.kill()
|
|
2394
|
+
except Exception as exc:
|
|
2395
|
+
_log.warning(
|
|
2396
|
+
"pty: idle kill raised (session %s): %s",
|
|
2397
|
+
session_id,
|
|
2398
|
+
exc,
|
|
2399
|
+
)
|
|
2400
|
+
|
|
2401
|
+
try:
|
|
2402
|
+
asyncio.get_running_loop().create_task(_kill_then_log())
|
|
2403
|
+
except RuntimeError:
|
|
2404
|
+
# Loop already closed (e.g. during interpreter shutdown).
|
|
2405
|
+
# The OS will reap the orphaned child; nothing more to do.
|
|
2406
|
+
pass
|