voxa-code 0.1.0__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.
@@ -0,0 +1,580 @@
1
+ """Attach-mode controller for Loop.
2
+
3
+ Runs an interactive ``claude`` inside a tmux session that the user can BOTH watch
4
+ and type into on the laptop (a Terminal window attached to the session) AND drive
5
+ by voice from the phone (we inject transcribed text with ``tmux send-keys``).
6
+
7
+ It implements the same interface as :class:`ClaudeController`
8
+ (``status``, ``working_dir``, ``on_final``, ``start``, ``send``, ``stop``) so the
9
+ orchestrator and server use the two interchangeably.
10
+
11
+ Speaking results back is best-effort: we capture the tmux pane, strip the TUI
12
+ chrome, and return the new text since the prompt was sent. The laptop terminal is
13
+ always the source of truth.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import inspect
20
+ import logging
21
+ import os
22
+ import json
23
+ import re
24
+ import shlex
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ from typing import Awaitable, Callable, Optional, Sequence
30
+
31
+
32
+ def _claude_launch_cmd() -> str:
33
+ """`claude` invocation for the Voxa-driven session. By default it uses the
34
+ user's NORMAL environment (their plugins, MCP, hooks) so it behaves like their
35
+ own Claude. Set VOXA_ISOLATE_CLAUDE=1 to run in an isolated config dir instead
36
+ (no global hooks/plugins) if some hook interferes; auth stays in the Keychain."""
37
+ base = "claude --dangerously-skip-permissions"
38
+ if os.environ.get("VOXA_ISOLATE_CLAUDE", "").strip().lower() not in ("1", "true", "yes"):
39
+ return base
40
+ cfg = os.path.expanduser("~/.voxa/claude-config")
41
+ try:
42
+ os.makedirs(cfg, exist_ok=True)
43
+ sp = os.path.join(cfg, "settings.json")
44
+ if not os.path.exists(sp):
45
+ with open(sp, "w") as f:
46
+ json.dump({
47
+ "permissions": {"defaultMode": "bypassPermissions"},
48
+ "skipDangerousModePermissionPrompt": True,
49
+ "hasCompletedOnboarding": True,
50
+ "theme": "dark",
51
+ }, f)
52
+ except OSError:
53
+ return base
54
+ return f"CLAUDE_CONFIG_DIR={shlex.quote(cfg)} {base}"
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ FinalCallback = Callable[[str], Awaitable[None]] | Callable[[str], None]
59
+ # A tmux runner runs ``tmux <args>`` and returns stdout; raises on failure.
60
+ TmuxRunner = Callable[[Sequence[str]], str]
61
+
62
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
63
+ _BORDER_CHARS = set("─━│┃╭╮╰╯┌┐└┘├┤┬┴┼ =·•")
64
+ # Spinner / bullet glyphs Claude Code prints at the start of status lines.
65
+ _SPINNER_PREFIX = "✶✻✽✢✣✤✥◐◓◑◒*•◦›❯⏵"
66
+ # Substrings that mark Claude Code's own interface noise (not its actual answer).
67
+ _NOISE = (
68
+ "mcp", "esc to interrupt", "for shortcuts", "ctrl+", "shift+tab",
69
+ "bypass permissions", "auto-update", "/doctor", "release-notes",
70
+ "what's new", "tips for getting started", "welcome back", "welcome to",
71
+ "claude code v", "to expand", "1m context", "/1m", "tokens", "-- insert",
72
+ "accept edits", "plan mode", "for agents", "/effort", "/model",
73
+ )
74
+ # Claude Code's status bar / footer carries these glyphs (model, effort level, usage,
75
+ # cost, mode). It's pure UI chrome — never relay it to the operator or show it in the
76
+ # live view (the agent kept reading "⚡ xhigh /effort" and asking the user about it).
77
+ _STATUS_GLYPHS = "⚡🤖💰📅⊙⏵⏷◀▸"
78
+ # Whimsical "working" verbs Claude shows in its spinner (Crunched/Sautéing/...).
79
+ _WORK_TIMER_RE = re.compile(r"\bfor\s+\d+s\b", re.IGNORECASE)
80
+ _COST_RE = re.compile(r"\$\d")
81
+
82
+
83
+ def _make_default_runner(socket: Optional[str]) -> TmuxRunner:
84
+ """Build a tmux runner. A named ``socket`` uses a private server with NO user
85
+ config (so a broken ~/.tmux.conf can't break Loop) — used for sessions Loop
86
+ launches. ``socket=None`` targets the user's DEFAULT tmux server, used when
87
+ attaching to a session the user already started there."""
88
+ base = ["tmux"] + (["-L", socket, "-f", "/dev/null"] if socket else [])
89
+
90
+ def run(args: Sequence[str]) -> str:
91
+ proc = subprocess.run(base + list(args), capture_output=True, text=True)
92
+ if proc.returncode != 0:
93
+ raise RuntimeError(f"tmux {list(args)} failed: {proc.stderr.strip()}")
94
+ return proc.stdout
95
+ return run
96
+
97
+
98
+ def _resolve_terminal_app(choice: str) -> str:
99
+ """Pick the macOS terminal app: explicit choice, else auto-detect (prefer iTerm2)."""
100
+ choice = (choice or "auto").strip()
101
+ if choice.lower() in ("iterm", "iterm2"):
102
+ return "iTerm"
103
+ if choice.lower() == "terminal":
104
+ return "Terminal"
105
+ # auto
106
+ return "iTerm" if os.path.isdir("/Applications/iTerm.app") else "Terminal"
107
+
108
+
109
+ def clean_pane(text: str) -> str:
110
+ """Strip ANSI escapes and Claude-Code TUI chrome (borders, prompt, spinners,
111
+ MCP/status/cost noise) so only Claude's actual output reaches the user."""
112
+ text = _ANSI_RE.sub("", text)
113
+ out: list[str] = []
114
+ for raw in text.splitlines():
115
+ stripped = raw.strip()
116
+ if not stripped:
117
+ continue
118
+ if set(stripped) <= _BORDER_CHARS: # box-drawing borders
119
+ continue
120
+ if stripped[0] in "│┃>" or stripped[0] in _SPINNER_PREFIX: # input box / spinner
121
+ continue
122
+ low = stripped.lower()
123
+ if any(n in low for n in _NOISE): # MCP/tips/status-bar noise
124
+ continue
125
+ if _WORK_TIMER_RE.search(low): # "Crunched for 5s" spinner lines
126
+ continue
127
+ if _COST_RE.search(stripped) and "%" in stripped: # the cost/token status bar
128
+ continue
129
+ if any(g in stripped for g in _STATUS_GLYPHS): # model/effort/usage footer
130
+ continue
131
+ out.append(stripped)
132
+ return "\n".join(out)
133
+
134
+
135
+ def clean_pane_with_color(text: str, max_lines: int = 200, max_bytes: int = 16000) -> str:
136
+ """Like clean_pane, but KEEP each surviving line's ANSI colour escapes (the phone
137
+ parses them into coloured text). Every filter DECISION still runs against an
138
+ ANSI-stripped copy of the line, so colour bytes can't smuggle chrome/noise past the
139
+ substring checks. Leading indentation is preserved; bounded to the last
140
+ ``max_lines`` / ``max_bytes`` to protect the socket and the iOS Text view."""
141
+ out: list[str] = []
142
+ for raw in text.splitlines():
143
+ visible = _ANSI_RE.sub("", raw)
144
+ stripped = visible.strip()
145
+ if not stripped:
146
+ continue
147
+ if set(stripped) <= _BORDER_CHARS:
148
+ continue
149
+ if stripped[0] in "│┃>" or stripped[0] in _SPINNER_PREFIX:
150
+ continue
151
+ low = stripped.lower()
152
+ if any(n in low for n in _NOISE):
153
+ continue
154
+ if _WORK_TIMER_RE.search(low):
155
+ continue
156
+ if _COST_RE.search(stripped) and "%" in stripped:
157
+ continue
158
+ if any(g in stripped for g in _STATUS_GLYPHS): # model/effort/usage footer
159
+ continue
160
+ out.append(raw.rstrip()) # keep colour escapes + leading indent; trim trailing pad
161
+ out = out[-max_lines:]
162
+ s = "\n".join(out)
163
+ data = s.encode("utf-8") # bound by BYTES, not code points
164
+ if len(data) > max_bytes:
165
+ s = data[-max_bytes:].decode("utf-8", errors="ignore")
166
+ return s
167
+
168
+
169
+ def new_text(before: str, after: str) -> str:
170
+ """Best-effort: lines present in ``after`` but not in ``before`` (else all of after)."""
171
+ before_lines = set(before.splitlines())
172
+ fresh = [ln for ln in after.splitlines() if ln not in before_lines]
173
+ return "\n".join(fresh) if fresh else after
174
+
175
+
176
+ def stable_key(text: str) -> str:
177
+ """Normalize a screen for change-detection: drop the remaining volatile chrome and
178
+ ignore ticking numbers, so 'idle' is detected even while timers/costs change."""
179
+ out = []
180
+ for ln in clean_pane(text).splitlines():
181
+ low = ln.lower()
182
+ if any(k in low for k in (
183
+ "esc to interrupt", "tokens", "context left", "auto-update",
184
+ "/doctor", "crunch", "✶", "✻", "✽",
185
+ )):
186
+ continue
187
+ out.append(re.sub(r"\d+", "#", ln)) # ignore changing numbers
188
+ return "\n".join(out)
189
+
190
+
191
+ # Claude shows these only while actively generating; their presence means "working".
192
+ _ACTIVE_MARKERS = ("esc to interrupt", "esc to cancel")
193
+
194
+ _MENU_RE = re.compile(r"^\s*[>❯]?\s*\d+[.)]\s+\S", re.MULTILINE)
195
+ _YESNO_RE = re.compile(r"\(y/n\)|\[y/n\]|\(yes/no\)", re.IGNORECASE)
196
+ _PROMPT_WORDS = ("do you trust", "allow ", "permission", "proceed?", "overwrite",
197
+ "continue?")
198
+
199
+
200
+ def looks_actionable(text: str) -> bool:
201
+ """True when the screen is a real prompt waiting on the user: a numbered menu (>=2
202
+ options), a (y/n), or a trust/permission question. Lets a fresh session announce
203
+ ONLY for an actual prompt, never for a plain idle prompt (which would call the user
204
+ the moment they start a session)."""
205
+ body = text or ""
206
+ if _YESNO_RE.search(body):
207
+ return True
208
+ low = body.lower()
209
+ if any(w in low for w in _PROMPT_WORDS):
210
+ return True
211
+ return len(_MENU_RE.findall(body)) >= 2
212
+
213
+
214
+ async def monitor_loop(ctrl) -> None:
215
+ """Shared live monitor: announce new screen content when the controller's
216
+ ``_capture()`` stabilises (Claude finished, or is waiting on a prompt).
217
+
218
+ ``ctrl`` must expose: ``_capture()``, ``_started``, ``_poll``, ``_idle_polls``,
219
+ ``status`` and an async ``_emit(text)``. Used by both the tmux and iTerm controllers.
220
+ """
221
+ try:
222
+ baseline = ctrl._capture()
223
+ except Exception:
224
+ return
225
+ last_key = stable_key(baseline)
226
+ announced = clean_pane(baseline)
227
+ stable = 0
228
+ active = False
229
+ while ctrl._started:
230
+ await asyncio.sleep(ctrl._poll)
231
+ try:
232
+ cur = ctrl._capture()
233
+ except Exception:
234
+ break # session gone
235
+ key = stable_key(cur)
236
+ if key != last_key:
237
+ last_key = key
238
+ stable = 0
239
+ active = True
240
+ emit_output = getattr(ctrl, "_emit_output", None)
241
+ if emit_output is not None:
242
+ await emit_output(cur)
243
+ emit_output_color = getattr(ctrl, "_emit_output_color", None)
244
+ if emit_output_color is not None:
245
+ await emit_output_color(cur)
246
+ else:
247
+ stable += 1
248
+ if active and stable >= ctrl._idle_polls:
249
+ cur_clean = clean_pane(cur)
250
+ delta = new_text(announced, cur_clean)
251
+ announced = cur_clean
252
+ active = False
253
+ ctrl.status = "idle"
254
+ await ctrl._emit(delta)
255
+
256
+
257
+ class TmuxController:
258
+ def __init__(
259
+ self,
260
+ session_name: str = "voxa",
261
+ runner: Optional[TmuxRunner] = None,
262
+ launch_terminal: bool = True,
263
+ terminal_app: str = "auto",
264
+ socket: str = "voxa",
265
+ poll_interval: float = 1.2,
266
+ idle_polls: int = 3,
267
+ timeout: float = 180.0,
268
+ ):
269
+ self._socket = socket
270
+ self._run = runner or _make_default_runner(socket)
271
+ self._session = session_name
272
+ self._launch_terminal = launch_terminal
273
+ self._terminal_app = terminal_app
274
+ self._poll = poll_interval
275
+ self._idle_polls = idle_polls
276
+ self._timeout = timeout
277
+ self.status = "idle"
278
+ # True once this session has actually worked (or been sent a task) since the
279
+ # last announce. Gates the "finished" announce so a fresh session that merely
280
+ # booted to its idle prompt does not ring the phone on startup.
281
+ self._saw_work = False
282
+ self.working_dir: Optional[str] = None
283
+ # Set by start() when the visible terminal window could NOT be opened, so the
284
+ # caller can tell the user how to attach manually (e.g. Automation denied).
285
+ self.window_hint = ""
286
+ self._final_cb: Optional[FinalCallback] = None
287
+ # Live-output callbacks: stream Claude's current screen to the UI while it
288
+ # works. _on_output gets plain text (back-compat); _on_output_color gets the
289
+ # same lines with ANSI colour escapes kept, for the terminal-themed view.
290
+ self._on_output = None
291
+ self._on_output_color = None
292
+ self._started = False
293
+ self._monitor_task: Optional[asyncio.Task] = None
294
+
295
+ def set_terminal_app(self, app: str) -> None:
296
+ """Override which terminal app to open (e.g. from a phone setting)."""
297
+ if app:
298
+ self._terminal_app = app
299
+
300
+ def on_final(self, cb: FinalCallback) -> None:
301
+ self._final_cb = cb
302
+
303
+ def on_output(self, cb) -> None:
304
+ """Register a callback that receives Claude's live screen text (cleaned)."""
305
+ self._on_output = cb
306
+
307
+ def on_output_color(self, cb) -> None:
308
+ """Register a callback that receives Claude's live screen WITH ANSI colour."""
309
+ self._on_output_color = cb
310
+
311
+ def _capture(self) -> str:
312
+ # -e preserves SGR colour escapes (the live colour feed parses them on the
313
+ # phone). clean_pane and _stable_key still strip ANSI, so idle detection and
314
+ # the spoken finals are unaffected by this.
315
+ return self._run(["capture-pane", "-p", "-e", "-t", self._session])
316
+
317
+ def capture_scrollback(self, lines: int = 1200) -> str:
318
+ """Capture the pane PLUS scrollback history (coloured, chrome-stripped) for the
319
+ phone's full-screen terminal view. On-demand only (heavier than the live pane
320
+ feed) — `-S -N` reaches back into history."""
321
+ try:
322
+ raw = self._run(["capture-pane", "-p", "-e", "-S", f"-{lines}", "-t", self._session])
323
+ except Exception:
324
+ return ""
325
+ return clean_pane_with_color(raw, max_lines=lines, max_bytes=128000)
326
+
327
+ def _has_session(self) -> bool:
328
+ try:
329
+ self._run(["has-session", "-t", self._session])
330
+ return True
331
+ except Exception:
332
+ return False
333
+
334
+ def _has_client(self) -> bool:
335
+ """True if a terminal window is currently attached to the session, so we
336
+ don't open a duplicate, but DO open one if the window was closed."""
337
+ try:
338
+ return bool(self._run(["list-clients", "-t", self._session]).strip())
339
+ except Exception:
340
+ return False
341
+
342
+ def _session_path(self) -> str:
343
+ """The folder the existing tmux session is actually in (its pane's cwd), so
344
+ a stale session from a previous run isn't reused for a different folder."""
345
+ try:
346
+ return self._run(
347
+ ["display-message", "-p", "-t", self._session, "#{pane_current_path}"]
348
+ ).strip()
349
+ except Exception:
350
+ return ""
351
+
352
+ async def start(self, working_dir: str) -> None:
353
+ path = os.path.abspath(os.path.expanduser(working_dir))
354
+ if not os.path.isdir(path):
355
+ raise ValueError(f"not a directory: {working_dir}")
356
+ self.working_dir = path
357
+ self.status = "idle"
358
+
359
+ # An explicit "open/start a session" ALWAYS starts fresh: kill any existing
360
+ # session (a leftover from a previous run, or a different project) and
361
+ # relaunch claude clean in the requested folder. (Plain phone reconnects do
362
+ # NOT call start(), so a running session still persists across reconnects.)
363
+ if self._has_session():
364
+ try:
365
+ self._run(["kill-session", "-t", self._session])
366
+ except Exception:
367
+ pass
368
+ existed = False
369
+ if not existed:
370
+ # Run interactive claude inside a detached tmux session in the project dir.
371
+ # Launch via a login shell so the user's PATH (e.g. ~/.local/bin) is loaded,
372
+ # and drop back to an interactive shell when claude exits so the window stays.
373
+ shell = os.environ.get("SHELL", "/bin/bash")
374
+ self._run([
375
+ "new-session", "-d", "-s", self._session, "-c", path,
376
+ "-x", "220", "-y", "50",
377
+ shell, "-lc", f"{_claude_launch_cmd()}; exec {shell} -il",
378
+ ])
379
+ self._started = True
380
+
381
+ # Open a terminal window so the user can SEE the session. Open it when we
382
+ # just made the session, OR when one exists but no window is attached (a
383
+ # lingering detached session, or the user closed the window). Skip only when
384
+ # a window is already attached, to avoid duplicates on reconnect.
385
+ self.window_hint = ""
386
+ if self._launch_terminal and sys.platform == "darwin" and (not existed or not self._has_client()):
387
+ if not self._open_terminal():
388
+ self.window_hint = (
389
+ "Couldn't open the terminal window automatically (check macOS "
390
+ "Automation permission for your terminal app). To see it, run: "
391
+ f"tmux -L {self._socket} attach -t {self._session}")
392
+
393
+ # Start (or restart) the live monitor that watches the pane and surfaces
394
+ # whatever Claude says or asks to the user by voice.
395
+ if self._monitor_task and not self._monitor_task.done():
396
+ self._monitor_task.cancel()
397
+ self._monitor_task = asyncio.ensure_future(self._monitor())
398
+
399
+ def _open_terminal(self) -> bool:
400
+ # Write an attach script so the window never just vanishes: it re-attaches while
401
+ # the session lives, and falls back to an interactive shell if the session ends
402
+ # (iTerm/Terminal close a window the moment its command returns, which caused the
403
+ # "window flashes then disappears" bug).
404
+ sock, sess = self._socket, self._session
405
+ # Absolute tmux path: the terminal window runs a non-login shell whose PATH may
406
+ # not include Homebrew, so a bare "tmux" is not found.
407
+ tmux_bin = shutil.which("tmux") or "tmux"
408
+ script_path = os.path.join(tempfile.gettempdir(), f"voxa-attach-{sock}.sh")
409
+ body = (
410
+ "#!/bin/bash\n"
411
+ f'echo "Voxa — attaching to your Claude session ({sess})..."\n'
412
+ "while true; do\n"
413
+ f" {tmux_bin} -L {sock} -f /dev/null attach -t {sess}\n"
414
+ f" {tmux_bin} -L {sock} -f /dev/null has-session -t {sess} 2>/dev/null || break\n"
415
+ " sleep 0.5\n"
416
+ "done\n"
417
+ 'echo "Voxa session ended. (window kept open)"\n'
418
+ 'exec "$SHELL" -il\n'
419
+ )
420
+ try:
421
+ with open(script_path, "w") as f:
422
+ f.write(body)
423
+ os.chmod(script_path, 0o755)
424
+ except OSError:
425
+ logger.exception("could not write attach script")
426
+ return False
427
+
428
+ cmd = f"bash {script_path}"
429
+ # Try the preferred terminal app, then fall back to the other if its
430
+ # AppleScript fails (e.g. iTerm isn't authorised for Automation, or isn't
431
+ # installed). Returns True only if a window was actually opened.
432
+ preferred = _resolve_terminal_app(self._terminal_app)
433
+ order = ["iTerm", "Terminal"] if preferred == "iTerm" else ["Terminal", "iTerm"]
434
+ for app in order:
435
+ if self._run_open_script(app, cmd):
436
+ return True
437
+ logger.warning(
438
+ "could not open a terminal window (Automation permission?); "
439
+ "attach manually: tmux -L %s attach -t %s", self._socket, self._session)
440
+ return False
441
+
442
+ @staticmethod
443
+ def _open_script_for(app: str, cmd: str) -> str:
444
+ if app == "iTerm":
445
+ return ('tell application "iTerm"\n'
446
+ " activate\n"
447
+ f' create window with default profile command "{cmd}"\n'
448
+ "end tell")
449
+ return (f'tell application "Terminal" to do script "{cmd}"\n'
450
+ 'tell application "Terminal" to activate')
451
+
452
+ def _run_open_script(self, app: str, cmd: str) -> bool:
453
+ """Run the open-window AppleScript for `app`; True if it actually succeeded
454
+ (osascript exits non-zero when Automation is denied or the app is missing)."""
455
+ try:
456
+ r = subprocess.run(["osascript", "-e", self._open_script_for(app, cmd)],
457
+ capture_output=True, text=True, timeout=10)
458
+ if r.returncode == 0:
459
+ return True
460
+ logger.warning("osascript open via %s failed: %s", app, (r.stderr or "").strip())
461
+ except Exception:
462
+ logger.exception("osascript open via %s raised", app)
463
+ return False
464
+
465
+ async def send(self, text: str) -> None:
466
+ """Inject the user's words into the live claude session. Returns immediately;
467
+ the monitor announces whatever Claude says or asks next."""
468
+ if not self._started:
469
+ raise ValueError("call start() before send()")
470
+ self.status = "working"
471
+ self._saw_work = True # a dispatched task should announce when it finishes
472
+ try:
473
+ self._run(["send-keys", "-t", self._session, "-l", text])
474
+ await asyncio.sleep(0.15)
475
+ self._run(["send-keys", "-t", self._session, "Enter"])
476
+ except Exception:
477
+ logger.exception("tmux send failed")
478
+ self.status = "error"
479
+
480
+ def _stable_key(self, text: str) -> str:
481
+ """Normalize a pane for change-detection: drop volatile chrome (spinners, the
482
+ status bar, ticking timers/costs) so 'idle' is detected even though those keep
483
+ changing."""
484
+ out = []
485
+ for ln in clean_pane(text).splitlines():
486
+ low = ln.lower()
487
+ if any(k in low for k in (
488
+ "esc to interrupt", "tokens", "context left", "auto-update",
489
+ "/doctor", "crunch", "✶", "✻", "✽",
490
+ )):
491
+ continue
492
+ out.append(re.sub(r"\d+", "#", ln)) # ignore changing numbers
493
+ return "\n".join(out)
494
+
495
+ async def _emit(self, text: str) -> None:
496
+ if text.strip() and self._final_cb is not None:
497
+ result = self._final_cb(text)
498
+ if inspect.isawaitable(result):
499
+ await result
500
+
501
+ async def _emit_output(self, raw: str) -> None:
502
+ """Push the current (cleaned) screen to the live-output UI, if anyone's
503
+ listening. Throttled naturally by the monitor's poll interval."""
504
+ if self._on_output is None:
505
+ return
506
+ text = clean_pane(raw)
507
+ if not text.strip():
508
+ return
509
+ result = self._on_output(text)
510
+ if inspect.isawaitable(result):
511
+ await result
512
+
513
+ async def _emit_output_color(self, raw: str) -> None:
514
+ """Push the current screen WITH colour to the terminal-themed UI, if anyone's
515
+ listening. Mirrors _emit_output; throttled by the monitor's poll interval."""
516
+ if self._on_output_color is None:
517
+ return
518
+ text = clean_pane_with_color(raw)
519
+ if not text.strip():
520
+ return
521
+ result = self._on_output_color(text)
522
+ if inspect.isawaitable(result):
523
+ await result
524
+
525
+ async def _monitor(self) -> None:
526
+ """Watch the pane; when Claude stops changing (finished, or waiting on a
527
+ question/menu/permission prompt), announce the new screen content so the
528
+ operator can read it to the user and ask what to do."""
529
+ try:
530
+ baseline = self._capture()
531
+ except Exception:
532
+ return
533
+ last_key = self._stable_key(baseline)
534
+ announced = clean_pane(baseline)
535
+ stable = 0
536
+ active = False
537
+ while self._started:
538
+ await asyncio.sleep(self._poll)
539
+ try:
540
+ cur = self._capture()
541
+ except Exception:
542
+ break # session gone
543
+ if any(m in cur.lower() for m in _ACTIVE_MARKERS):
544
+ self._saw_work = True
545
+ key = self._stable_key(cur)
546
+ if key != last_key:
547
+ last_key = key
548
+ stable = 0
549
+ active = True
550
+ await self._emit_output(cur) # plain live stream (back-compat)
551
+ await self._emit_output_color(cur) # colour live stream (themed view)
552
+ else:
553
+ stable += 1
554
+ if active and stable >= self._idle_polls:
555
+ cur_clean = clean_pane(cur)
556
+ delta = new_text(announced, cur_clean)
557
+ announced = cur_clean
558
+ active = False
559
+ self.status = "idle"
560
+ # Announce only a real finish (work happened) or a genuine prompt;
561
+ # a fresh boot settling to its idle prompt must not fire (it would
562
+ # ring the phone the moment a session starts).
563
+ if self._saw_work or looks_actionable(cur_clean):
564
+ self._saw_work = False
565
+ await self._emit(delta)
566
+
567
+ async def stop(self, *, detach_only: bool = False) -> None:
568
+ # Do NOT kill the tmux session: the laptop terminal must stay usable after the
569
+ # phone hangs up. Stop the monitor and (unless detach_only) send an interrupt
570
+ # (Escape) to halt any in-progress generation. detach_only is used when swapping
571
+ # to another terminal, so the session we leave keeps running its task.
572
+ self._started = False
573
+ if self._monitor_task and not self._monitor_task.done():
574
+ self._monitor_task.cancel()
575
+ if not detach_only:
576
+ try:
577
+ self._run(["send-keys", "-t", self._session, "Escape"])
578
+ except Exception:
579
+ pass
580
+ self.status = "idle"