agent-decoder 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
@@ -0,0 +1,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