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.
server/terminals.py ADDED
@@ -0,0 +1,510 @@
1
+ """Discover already-open Claude sessions and attach to them.
2
+
3
+ There is no universal way to control an arbitrary GUI terminal app, so control is
4
+ tiered (see the spec): tmux works everywhere; iTerm2/Terminal via scripting; others
5
+ are detected-only. v1 implements tmux + iTerm2 discovery and an iTerm2 controller.
6
+ The tmux controller (tmux_controller.TmuxController) is reused for tmux attach.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import inspect
13
+ import logging
14
+ import os
15
+ import subprocess
16
+ from typing import Callable, Optional, Sequence
17
+
18
+ from .tmux_controller import (
19
+ FinalCallback, clean_pane, clean_pane_with_color, monitor_loop,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Seams so tests can inject fakes instead of touching the real shell / AppleScript.
25
+ Runner = Callable[[Sequence[str]], str]
26
+ OsaRunner = Callable[[str], str]
27
+
28
+
29
+ def _shell(args: Sequence[str]) -> str:
30
+ try:
31
+ p = subprocess.run(list(args), capture_output=True, text=True, timeout=8)
32
+ return p.stdout
33
+ except Exception:
34
+ return ""
35
+
36
+
37
+ def _osa(script: str) -> str:
38
+ try:
39
+ p = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=10)
40
+ return p.stdout
41
+ except Exception:
42
+ return ""
43
+
44
+
45
+ def _cwd_of_pid(pid: str, run: Runner) -> str:
46
+ for line in run(["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"]).splitlines():
47
+ if line.startswith("n"):
48
+ return line[1:]
49
+ return ""
50
+
51
+
52
+ def _claude_pid_on_tty(tty: str, run: Runner) -> Optional[str]:
53
+ """Return the pid of a `claude` process attached to this tty, if any."""
54
+ ttyname = tty.replace("/dev/", "")
55
+ for raw in run(["ps", "-t", ttyname, "-o", "pid=,command="]).splitlines():
56
+ raw = raw.strip()
57
+ if not raw:
58
+ continue
59
+ pid_str, _, cmd = raw.partition(" ")
60
+ if "claude" in cmd.lower():
61
+ return pid_str.strip()
62
+ return None
63
+
64
+
65
+ _ITERM_LIST = (
66
+ 'tell application "iTerm2"\n'
67
+ ' set out to ""\n'
68
+ ' repeat with w in windows\n'
69
+ ' repeat with t in tabs of w\n'
70
+ ' repeat with s in sessions of t\n'
71
+ ' set out to out & (id of s) & "\t" & (tty of s) & "\t" & (name of s) & "\n"\n'
72
+ ' end repeat\n'
73
+ ' end repeat\n'
74
+ ' end repeat\n'
75
+ ' return out\n'
76
+ 'end tell'
77
+ )
78
+
79
+
80
+ def discover_iterm(run: Runner = _shell, osa: OsaRunner = _osa) -> list[dict]:
81
+ """List iTerm2 sessions that are running Claude."""
82
+ sessions: list[dict] = []
83
+ for line in osa(_ITERM_LIST).splitlines():
84
+ parts = line.split("\t")
85
+ if len(parts) < 2:
86
+ continue
87
+ sid, tty = parts[0].strip(), parts[1].strip()
88
+ name = parts[2].strip() if len(parts) > 2 else ""
89
+ if not sid or not tty:
90
+ continue
91
+ pid = _claude_pid_on_tty(tty, run)
92
+ if not pid:
93
+ continue
94
+ cwd = _cwd_of_pid(pid, run)
95
+ sessions.append({
96
+ "id": f"iterm:{sid}",
97
+ "raw_id": sid,
98
+ "cwd": cwd,
99
+ "label": os.path.basename(cwd) or name or "terminal",
100
+ "app": "iTerm2",
101
+ "backend": "iterm",
102
+ "controllable": True,
103
+ })
104
+ return sessions
105
+
106
+
107
+ def discover_tmux(run: Runner = _shell) -> list[dict]:
108
+ """List tmux sessions (default socket) whose active pane is running Claude."""
109
+ fmt = "#{session_name}\t#{pane_current_path}\t#{pane_current_command}"
110
+ out = run(["tmux", "list-panes", "-a", "-F", fmt])
111
+ seen: dict[str, dict] = {}
112
+ for line in out.splitlines():
113
+ parts = line.split("\t")
114
+ if len(parts) < 3:
115
+ continue
116
+ sess, path, cmd = parts[0].strip(), parts[1].strip(), parts[2].strip()
117
+ if "claude" not in cmd.lower():
118
+ continue
119
+ seen.setdefault(sess, {
120
+ "id": f"tmux::{sess}", # empty socket field == default socket
121
+ "raw_id": sess,
122
+ "socket": None,
123
+ "cwd": path,
124
+ "label": os.path.basename(path) or sess,
125
+ "app": "tmux",
126
+ "backend": "tmux",
127
+ "controllable": True,
128
+ })
129
+ return list(seen.values())
130
+
131
+
132
+ def list_claude_processes(run: Runner = _shell) -> list[dict]:
133
+ """Every `claude` process attached to a tty: process-first discovery ground truth."""
134
+ procs: list[dict] = []
135
+ for raw in run(["ps", "-axo", "pid=,tty=,command="]).splitlines():
136
+ parts = raw.split(None, 2)
137
+ if len(parts) < 3:
138
+ continue
139
+ pid, tty, cmd = parts[0].strip(), parts[1].strip(), parts[2].strip()
140
+ if tty in ("??", "-", "") or "claude" not in cmd.lower():
141
+ continue
142
+ procs.append({"pid": pid, "tty": tty, "cwd": _cwd_of_pid(pid, run)})
143
+ return procs
144
+
145
+
146
+ _TERMINAL_APP_LIST = (
147
+ 'tell application "Terminal"\n'
148
+ ' set out to ""\n'
149
+ ' repeat with w in windows\n'
150
+ ' set ti to 1\n'
151
+ ' repeat with t in tabs of w\n'
152
+ ' set out to out & (id of w) & "\t" & ti & "\t" & (tty of t) & "\n"\n'
153
+ ' set ti to ti + 1\n'
154
+ ' end repeat\n'
155
+ ' end repeat\n'
156
+ ' return out\n'
157
+ 'end tell'
158
+ )
159
+
160
+
161
+ def discover_terminal_app(run: Runner = _shell, osa: OsaRunner = _osa) -> list[dict]:
162
+ """List Terminal.app tabs running Claude. Guarded by pgrep so the AppleScript
163
+ never launches Terminal when it is not already running."""
164
+ if not run(["pgrep", "-x", "Terminal"]).strip():
165
+ return []
166
+ sessions: list[dict] = []
167
+ for line in osa(_TERMINAL_APP_LIST).splitlines():
168
+ parts = line.split("\t")
169
+ if len(parts) < 3:
170
+ continue
171
+ wid, tab, tty = parts[0].strip(), parts[1].strip(), parts[2].strip()
172
+ if not wid or not tty:
173
+ continue
174
+ ttyname = tty.replace("/dev/", "")
175
+ pid = _claude_pid_on_tty(tty, run)
176
+ cwd = _cwd_of_pid(pid, run) if pid else ""
177
+ if not pid:
178
+ continue
179
+ sessions.append({
180
+ "id": f"term:{wid}:{tab}",
181
+ "raw_id": f"{wid}:{tab}",
182
+ "tty": ttyname,
183
+ "pid": pid,
184
+ "cwd": cwd,
185
+ "label": os.path.basename(cwd) or "Terminal",
186
+ "app": "Terminal",
187
+ "backend": "terminal_app",
188
+ "controllable": True,
189
+ })
190
+ return sessions
191
+
192
+
193
+ def gui_owner_of_tty(tty: str, run: Runner = _shell) -> tuple[str, str]:
194
+ """Walk process ancestry from the tty's shell up to the owning GUI app bundle.
195
+ Returns (app_name, app_pid), or ("", "") when no .app ancestor is found."""
196
+ ttyname = tty.replace("/dev/", "")
197
+ first = run(["ps", "-t", ttyname, "-o", "pid=,command="]).splitlines()
198
+ if not first:
199
+ return "", ""
200
+ pid = first[0].strip().split(None, 1)[0]
201
+ for _ in range(12):
202
+ out = run(["ps", "-o", "ppid=,command=", "-p", pid]).strip().splitlines()
203
+ if not out:
204
+ return "", ""
205
+ ppid, _, cmd = out[0].strip().partition(" ")
206
+ ppid, cmd = ppid.strip(), cmd.strip()
207
+ if ".app/Contents/MacOS/" in cmd:
208
+ app = cmd.split(".app/Contents/MacOS/")[0].rsplit("/", 1)[-1]
209
+ return app, pid
210
+ if ppid in ("0", "1", "", pid):
211
+ return "", ""
212
+ pid = ppid
213
+ return "", ""
214
+
215
+
216
+ def _tty_of_tmux_panes(run: Runner) -> set[str]:
217
+ try:
218
+ out = run(["tmux", "list-panes", "-a", "-F", "#{pane_tty}"])
219
+ except Exception:
220
+ return set()
221
+ return {ln.strip().replace("/dev/", "") for ln in out.splitlines() if ln.strip()}
222
+
223
+
224
+ def discover_claude_sessions(run: Runner = _shell, osa: OsaRunner = _osa) -> list[dict]:
225
+ """Process-first union of every open Claude session, deduped by tty with
226
+ priority tmux > iTerm2 > Terminal.app > ax (universal fallback)."""
227
+ found: list[dict] = []
228
+ claimed: set[str] = set()
229
+
230
+ try:
231
+ found.extend(discover_tmux(run))
232
+ claimed |= _tty_of_tmux_panes(run)
233
+ except Exception:
234
+ logger.exception("tmux discovery failed")
235
+ try:
236
+ for s in discover_iterm(run, osa):
237
+ found.append(s)
238
+ # iTerm discovery already knows each session's tty; claim them
239
+ for line in osa(_ITERM_LIST).splitlines():
240
+ parts = line.split("\t")
241
+ if len(parts) >= 2 and parts[1].strip():
242
+ claimed.add(parts[1].strip().replace("/dev/", ""))
243
+ except Exception:
244
+ logger.exception("iterm discovery failed")
245
+ try:
246
+ for s in discover_terminal_app(run, osa):
247
+ if s["tty"] not in claimed:
248
+ found.append(s)
249
+ claimed.add(s["tty"])
250
+ except Exception:
251
+ logger.exception("Terminal.app discovery failed")
252
+
253
+ # Universal fallback: any claude on an unclaimed tty gets an ax session.
254
+ try:
255
+ for p in list_claude_processes(run):
256
+ if p["tty"] in claimed:
257
+ continue
258
+ app, app_pid = gui_owner_of_tty(p["tty"], run)
259
+ found.append({
260
+ "id": f"ax:{p['tty']}",
261
+ "raw_id": p["tty"],
262
+ "tty": p["tty"],
263
+ "pid": p["pid"],
264
+ "app_pid": app_pid,
265
+ "cwd": p["cwd"],
266
+ "label": os.path.basename(p["cwd"]) or (app or "terminal"),
267
+ "app": app or "terminal",
268
+ "backend": "ax",
269
+ # Only controllable if we resolved the owning GUI app: keystrokes
270
+ # post to that pid. No owner (e.g. a detached tmux on a private
271
+ # socket, ssh, or a headless process) means detected-only.
272
+ "controllable": bool(app_pid),
273
+ })
274
+ claimed.add(p["tty"])
275
+ except Exception:
276
+ logger.exception("ax discovery failed")
277
+ return found
278
+
279
+
280
+ def _iterm_capture_script(sid: str) -> str:
281
+ """AppleScript that returns the contents of the iTerm2 session with id ``sid``.
282
+
283
+ iTerm2 has no ``session id "X"`` element specifier (that raises error -1728),
284
+ so we loop windows/tabs/sessions and match on the ``id`` property instead.
285
+ """
286
+ return (
287
+ 'tell application "iTerm2"\n'
288
+ ' repeat with w in windows\n'
289
+ ' repeat with t in tabs of w\n'
290
+ ' repeat with s in sessions of t\n'
291
+ f' if (id of s) is "{sid}" then return contents of s\n'
292
+ ' end repeat\n'
293
+ ' end repeat\n'
294
+ ' end repeat\n'
295
+ ' return ""\n'
296
+ 'end tell'
297
+ )
298
+
299
+
300
+ def _iterm_write_script(sid: str, text: str) -> str:
301
+ """AppleScript that types ``text`` into the iTerm2 session with id ``sid``.
302
+ Same loop-and-match approach as :func:`_iterm_capture_script`."""
303
+ esc = text.replace("\\", "\\\\").replace('"', '\\"')
304
+ return (
305
+ 'tell application "iTerm2"\n'
306
+ ' repeat with w in windows\n'
307
+ ' repeat with t in tabs of w\n'
308
+ ' repeat with s in sessions of t\n'
309
+ f' if (id of s) is "{sid}" then tell s to write text "{esc}"\n'
310
+ ' end repeat\n'
311
+ ' end repeat\n'
312
+ ' end repeat\n'
313
+ 'end tell'
314
+ )
315
+
316
+
317
+ class ItermController:
318
+ """Drives an already-open iTerm2 session running Claude. Same interface as
319
+ TmuxController. stop() NEVER closes the user's terminal; it only detaches."""
320
+
321
+ def __init__(
322
+ self,
323
+ session_id: str,
324
+ osa: OsaRunner = _osa,
325
+ poll_interval: float = 1.2,
326
+ idle_polls: int = 3,
327
+ ):
328
+ self._sid = session_id # raw iTerm session id (no "iterm:" prefix)
329
+ self._osa = osa
330
+ self._poll = poll_interval
331
+ self._idle_polls = idle_polls
332
+ self.status = "idle"
333
+ self.working_dir: Optional[str] = None
334
+ self._final_cb: Optional[FinalCallback] = None
335
+ self._on_output = None
336
+ self._on_output_color = None
337
+ self._started = False
338
+ self._monitor_task: Optional[asyncio.Task] = None
339
+
340
+ def on_final(self, cb: FinalCallback) -> None:
341
+ self._final_cb = cb
342
+
343
+ def on_output(self, cb) -> None:
344
+ self._on_output = cb
345
+
346
+ def on_output_color(self, cb) -> None:
347
+ self._on_output_color = cb
348
+
349
+ async def _emit_output(self, raw: str) -> None:
350
+ if self._on_output is None:
351
+ return
352
+ text = clean_pane(raw)
353
+ if not text.strip():
354
+ return
355
+ result = self._on_output(text)
356
+ if inspect.isawaitable(result):
357
+ await result
358
+
359
+ async def _emit_output_color(self, raw: str) -> None:
360
+ if self._on_output_color is None:
361
+ return
362
+ text = clean_pane_with_color(raw)
363
+ if not text.strip():
364
+ return
365
+ result = self._on_output_color(text)
366
+ if inspect.isawaitable(result):
367
+ await result
368
+
369
+ def capture_scrollback(self, lines: int = 1200) -> str:
370
+ # iTerm AppleScript cannot reach full scrollback; return the visible
371
+ # screen (same loop-and-match capture the monitor uses), cleaned.
372
+ raw = self._osa(_iterm_capture_script(self._sid))
373
+ return clean_pane_with_color(raw, max_lines=lines, max_bytes=128000)
374
+
375
+ def set_terminal_app(self, app: str) -> None:
376
+ pass # parity with TmuxController; not meaningful when attached
377
+
378
+ def _capture(self) -> str:
379
+ return self._osa(_iterm_capture_script(self._sid))
380
+
381
+ async def _emit(self, text: str) -> None:
382
+ if text.strip() and self._final_cb is not None:
383
+ result = self._final_cb(text)
384
+ if inspect.isawaitable(result):
385
+ await result
386
+
387
+ async def start(self, working_dir: Optional[str] = None) -> None:
388
+ self._started = True
389
+ if working_dir:
390
+ self.working_dir = working_dir
391
+ self.status = "idle"
392
+ if self._monitor_task and not self._monitor_task.done():
393
+ self._monitor_task.cancel()
394
+ self._monitor_task = asyncio.ensure_future(monitor_loop(self))
395
+
396
+ async def send(self, text: str) -> None:
397
+ if not self._started:
398
+ raise ValueError("attach to a terminal before sending")
399
+ self.status = "working"
400
+ self._osa(_iterm_write_script(self._sid, text))
401
+
402
+ async def stop(self, *, detach_only: bool = False) -> None:
403
+ # Detach only. Never close the user's own terminal. (detach_only is accepted for
404
+ # a uniform controller interface; this controller never sends an interrupt.)
405
+ self._started = False
406
+ if self._monitor_task and not self._monitor_task.done():
407
+ self._monitor_task.cancel()
408
+ self.status = "idle"
409
+
410
+
411
+ class TerminalAppController:
412
+ """Drives an already-open Terminal.app tab running Claude. Same interface as
413
+ ItermController. stop() NEVER closes the user's tab; it only detaches."""
414
+
415
+ def __init__(
416
+ self,
417
+ raw_id: str, # "<window_id>:<tab_index>"
418
+ osa: OsaRunner = _osa,
419
+ poll_interval: float = 1.2,
420
+ idle_polls: int = 3,
421
+ ):
422
+ wid, _, tab = raw_id.partition(":")
423
+ self._wid, self._tab = wid, (tab or "1")
424
+ self._osa = osa
425
+ self._poll = poll_interval
426
+ self._idle_polls = idle_polls
427
+ self.status = "idle"
428
+ self.working_dir: Optional[str] = None
429
+ self._final_cb: Optional[FinalCallback] = None
430
+ self._on_output = None
431
+ self._on_output_color = None
432
+ self._started = False
433
+ self._monitor_task: Optional[asyncio.Task] = None
434
+
435
+ def on_final(self, cb: FinalCallback) -> None:
436
+ self._final_cb = cb
437
+
438
+ def on_output(self, cb) -> None:
439
+ self._on_output = cb
440
+
441
+ def on_output_color(self, cb) -> None:
442
+ self._on_output_color = cb
443
+
444
+ async def _emit_output(self, raw: str) -> None:
445
+ if self._on_output is None:
446
+ return
447
+ text = clean_pane(raw)
448
+ if not text.strip():
449
+ return
450
+ result = self._on_output(text)
451
+ if inspect.isawaitable(result):
452
+ await result
453
+
454
+ async def _emit_output_color(self, raw: str) -> None:
455
+ if self._on_output_color is None:
456
+ return
457
+ text = clean_pane_with_color(raw)
458
+ if not text.strip():
459
+ return
460
+ result = self._on_output_color(text)
461
+ if inspect.isawaitable(result):
462
+ await result
463
+
464
+ def capture_scrollback(self, lines: int = 1200) -> str:
465
+ raw = self._osa(
466
+ f'tell application "Terminal" to return history of {self._target()}'
467
+ )
468
+ return clean_pane_with_color(raw, max_lines=lines, max_bytes=128000)
469
+
470
+ def set_terminal_app(self, app: str) -> None:
471
+ pass # parity with the other controllers; not meaningful when attached
472
+
473
+ def _target(self) -> str:
474
+ return f"tab {self._tab} of window id {self._wid}"
475
+
476
+ def _capture(self) -> str:
477
+ return self._osa(
478
+ f'tell application "Terminal" to return history of {self._target()}'
479
+ )
480
+
481
+ async def _emit(self, text: str) -> None:
482
+ if text.strip() and self._final_cb is not None:
483
+ result = self._final_cb(text)
484
+ if inspect.isawaitable(result):
485
+ await result
486
+
487
+ async def start(self, working_dir: Optional[str] = None) -> None:
488
+ self._started = True
489
+ if working_dir:
490
+ self.working_dir = working_dir
491
+ self.status = "idle"
492
+ if self._monitor_task and not self._monitor_task.done():
493
+ self._monitor_task.cancel()
494
+ self._monitor_task = asyncio.ensure_future(monitor_loop(self))
495
+
496
+ async def send(self, text: str) -> None:
497
+ if not self._started:
498
+ raise ValueError("attach to a terminal before sending")
499
+ self.status = "working"
500
+ esc = text.replace("\\", "\\\\").replace('"', '\\"')
501
+ self._osa(
502
+ f'tell application "Terminal" to do script "{esc}" in {self._target()}'
503
+ )
504
+
505
+ async def stop(self, *, detach_only: bool = False) -> None:
506
+ # Detach only. Never close the user's own tab.
507
+ self._started = False
508
+ if self._monitor_task and not self._monitor_task.done():
509
+ self._monitor_task.cancel()
510
+ self.status = "idle"