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
|
@@ -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"
|