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/__init__.py +0 -0
- server/apns.py +89 -0
- server/app.py +589 -0
- server/appattest.py +310 -0
- server/appstore.py +141 -0
- server/attested_store.py +60 -0
- server/auth.py +70 -0
- server/ax_controller.py +202 -0
- server/billing.py +177 -0
- server/call_manager.py +91 -0
- server/certs/AppleRootCA-G3.pem +15 -0
- server/certs/Apple_App_Attestation_Root_CA.pem +14 -0
- server/claude_controller.py +156 -0
- server/cli.py +365 -0
- server/cloud_app.py +345 -0
- server/config.py +56 -0
- server/device_registry.py +52 -0
- server/gemini_operator.py +677 -0
- server/hooks.py +202 -0
- server/orchestrator.py +315 -0
- server/push_routes.py +50 -0
- server/ratelimit.py +41 -0
- server/relay.py +157 -0
- server/relay_client.py +89 -0
- server/remote_operator.py +128 -0
- server/session_hub.py +33 -0
- server/terminal_watcher.py +241 -0
- server/terminals.py +510 -0
- server/tmux_controller.py +580 -0
- server/transcript_monitor.py +134 -0
- server/transcripts.py +143 -0
- server/users.py +90 -0
- server/voxa_cloud.py +132 -0
- server/waitlist.py +130 -0
- static/app.js +388 -0
- static/favicon.svg +1 -0
- static/index.html +253 -0
- static/pcm-worklet.js +69 -0
- static/pro.html +29 -0
- static/pro2.html +33 -0
- static/voxa-mark-white.svg +1 -0
- voxa_code-0.1.0.dist-info/METADATA +227 -0
- voxa_code-0.1.0.dist-info/RECORD +47 -0
- voxa_code-0.1.0.dist-info/WHEEL +5 -0
- voxa_code-0.1.0.dist-info/entry_points.txt +2 -0
- voxa_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- voxa_code-0.1.0.dist-info/top_level.txt +2 -0
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"
|