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/ax_controller.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Universal terminal controller: Accessibility keystroke injection.
|
|
2
|
+
|
|
3
|
+
Covers any terminal app we have no scripting bridge for (Ghostty, Warp,
|
|
4
|
+
VS Code, ...). Input goes in as keyboard events posted straight to the app's
|
|
5
|
+
pid (no focus steal). Reading: apps that expose text through the Accessibility
|
|
6
|
+
tree get real screens; GPU terminals that do not are monitored through the
|
|
7
|
+
session transcript instead (TranscriptMonitor).
|
|
8
|
+
|
|
9
|
+
All system access is behind injectable seams so tests (and the Linux cloud
|
|
10
|
+
box, which installs the same package) never import pyobjc.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import inspect
|
|
17
|
+
import logging
|
|
18
|
+
import subprocess
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
from .tmux_controller import FinalCallback, monitor_loop, clean_pane, clean_pane_with_color
|
|
22
|
+
from .transcript_monitor import TranscriptMonitor
|
|
23
|
+
from .transcripts import PROJECTS_DIR
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
AX_PERMISSION_ERROR = "accessibility_permission_needed"
|
|
28
|
+
NO_LIVE_VIEW_NOTE = "Live view isn't available for this terminal."
|
|
29
|
+
_AX_SETTINGS_URL = ("x-apple.systempreferences:"
|
|
30
|
+
"com.apple.preference.security?Privacy_Accessibility")
|
|
31
|
+
_RETURN_KEYCODE = 36
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _quartz_post_keys(pid: int, text: str) -> None:
|
|
35
|
+
"""Default poster: type `text` + Return into the app via CGEventPostToPid."""
|
|
36
|
+
import Quartz # lazy: macOS-only
|
|
37
|
+
|
|
38
|
+
for ch in text:
|
|
39
|
+
for down in (True, False):
|
|
40
|
+
ev = Quartz.CGEventCreateKeyboardEvent(None, 0, down)
|
|
41
|
+
Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
|
|
42
|
+
Quartz.CGEventPostToPid(pid, ev)
|
|
43
|
+
for down in (True, False):
|
|
44
|
+
ev = Quartz.CGEventCreateKeyboardEvent(None, _RETURN_KEYCODE, down)
|
|
45
|
+
Quartz.CGEventPostToPid(pid, ev)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ax_capture(pid: int) -> str:
|
|
49
|
+
"""Default capturer: best-effort text from the app's AX tree ('' if none)."""
|
|
50
|
+
try:
|
|
51
|
+
from ApplicationServices import ( # lazy: macOS-only
|
|
52
|
+
AXUIElementCreateApplication, AXUIElementCopyAttributeValue,
|
|
53
|
+
)
|
|
54
|
+
app = AXUIElementCreateApplication(int(pid))
|
|
55
|
+
err, win = AXUIElementCopyAttributeValue(app, "AXFocusedWindow", None)
|
|
56
|
+
if err != 0 or win is None:
|
|
57
|
+
return ""
|
|
58
|
+
err, val = AXUIElementCopyAttributeValue(win, "AXValue", None)
|
|
59
|
+
return str(val) if err == 0 and val else ""
|
|
60
|
+
except Exception:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ax_trusted() -> bool:
|
|
65
|
+
try:
|
|
66
|
+
from ApplicationServices import AXIsProcessTrusted # lazy: macOS-only
|
|
67
|
+
return bool(AXIsProcessTrusted())
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _open_url(url: str) -> None:
|
|
73
|
+
try:
|
|
74
|
+
subprocess.Popen(["open", url])
|
|
75
|
+
except Exception:
|
|
76
|
+
logger.exception("could not open settings URL")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AXController:
|
|
80
|
+
"""Drives ANY terminal app running Claude via Accessibility keystrokes.
|
|
81
|
+
Same interface as the other controllers. stop() never touches the app."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
app_pid: int | str,
|
|
86
|
+
cwd: str,
|
|
87
|
+
*,
|
|
88
|
+
poster: Callable[[int, str], None] = _quartz_post_keys,
|
|
89
|
+
capturer: Callable[[int], str] = _ax_capture,
|
|
90
|
+
trusted: Callable[[], bool] = _ax_trusted,
|
|
91
|
+
opener: Callable[[str], None] = _open_url,
|
|
92
|
+
poll_interval: float = 1.2,
|
|
93
|
+
idle_polls: int = 3,
|
|
94
|
+
quiet_secs: float = 5.0,
|
|
95
|
+
projects_dir: str = PROJECTS_DIR,
|
|
96
|
+
):
|
|
97
|
+
self._pid = int(app_pid)
|
|
98
|
+
self._poster = poster
|
|
99
|
+
self._capturer = capturer
|
|
100
|
+
self._trusted = trusted
|
|
101
|
+
self._opener = opener
|
|
102
|
+
self._poll = poll_interval
|
|
103
|
+
self._idle_polls = idle_polls
|
|
104
|
+
self._quiet = quiet_secs
|
|
105
|
+
self._projects = projects_dir
|
|
106
|
+
self.status = "idle"
|
|
107
|
+
self.working_dir: Optional[str] = cwd
|
|
108
|
+
self._final_cb: Optional[FinalCallback] = None
|
|
109
|
+
self._on_output = None
|
|
110
|
+
self._on_output_color = None
|
|
111
|
+
self.mirrors_screen = True
|
|
112
|
+
self._started = False
|
|
113
|
+
self._monitor_task: Optional[asyncio.Task] = None
|
|
114
|
+
self._transcript_mon: Optional[TranscriptMonitor] = None
|
|
115
|
+
|
|
116
|
+
def on_final(self, cb: FinalCallback) -> None:
|
|
117
|
+
self._final_cb = cb
|
|
118
|
+
if self._transcript_mon is not None:
|
|
119
|
+
self._transcript_mon.on_final(cb)
|
|
120
|
+
|
|
121
|
+
def on_output(self, cb) -> None:
|
|
122
|
+
self._on_output = cb
|
|
123
|
+
|
|
124
|
+
def on_output_color(self, cb) -> None:
|
|
125
|
+
self._on_output_color = cb
|
|
126
|
+
|
|
127
|
+
async def _emit_output(self, raw: str) -> None:
|
|
128
|
+
if self._on_output is None:
|
|
129
|
+
return
|
|
130
|
+
text = clean_pane(raw)
|
|
131
|
+
if not text.strip():
|
|
132
|
+
return
|
|
133
|
+
result = self._on_output(text)
|
|
134
|
+
if inspect.isawaitable(result):
|
|
135
|
+
await result
|
|
136
|
+
|
|
137
|
+
async def _emit_output_color(self, raw: str) -> None:
|
|
138
|
+
if self._on_output_color is None:
|
|
139
|
+
return
|
|
140
|
+
text = clean_pane_with_color(raw)
|
|
141
|
+
if not text.strip():
|
|
142
|
+
return
|
|
143
|
+
result = self._on_output_color(text)
|
|
144
|
+
if inspect.isawaitable(result):
|
|
145
|
+
await result
|
|
146
|
+
|
|
147
|
+
def capture_scrollback(self, lines: int = 1200) -> str:
|
|
148
|
+
if not self.mirrors_screen:
|
|
149
|
+
return NO_LIVE_VIEW_NOTE
|
|
150
|
+
raw = self._capture()
|
|
151
|
+
return clean_pane_with_color(raw, max_lines=lines, max_bytes=128000)
|
|
152
|
+
|
|
153
|
+
def set_terminal_app(self, app: str) -> None:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def _capture(self) -> str:
|
|
157
|
+
return self._capturer(self._pid)
|
|
158
|
+
|
|
159
|
+
async def _emit(self, text: str) -> None:
|
|
160
|
+
if text.strip() and self._final_cb is not None:
|
|
161
|
+
result = self._final_cb(text)
|
|
162
|
+
if inspect.isawaitable(result):
|
|
163
|
+
await result
|
|
164
|
+
|
|
165
|
+
async def start(self, working_dir: Optional[str] = None) -> None:
|
|
166
|
+
if not self._trusted():
|
|
167
|
+
self._opener(_AX_SETTINGS_URL)
|
|
168
|
+
raise PermissionError(
|
|
169
|
+
f"{AX_PERMISSION_ERROR}: grant Accessibility permission to the "
|
|
170
|
+
"Voxa server in System Settings (the pane was just opened), "
|
|
171
|
+
"then attach again.")
|
|
172
|
+
if working_dir:
|
|
173
|
+
self.working_dir = working_dir
|
|
174
|
+
self._started = True
|
|
175
|
+
self.status = "idle"
|
|
176
|
+
if self._monitor_task and not self._monitor_task.done():
|
|
177
|
+
self._monitor_task.cancel()
|
|
178
|
+
first = self._capture()
|
|
179
|
+
self.mirrors_screen = bool(first.strip())
|
|
180
|
+
if self.mirrors_screen:
|
|
181
|
+
self._monitor_task = asyncio.ensure_future(monitor_loop(self))
|
|
182
|
+
else:
|
|
183
|
+
# GPU terminal with no AX text: transcript is the source of truth.
|
|
184
|
+
self._transcript_mon = TranscriptMonitor(
|
|
185
|
+
self.working_dir or "", self._final_cb,
|
|
186
|
+
poll_interval=self._poll, quiet_secs=self._quiet,
|
|
187
|
+
projects_dir=self._projects)
|
|
188
|
+
self._monitor_task = asyncio.ensure_future(self._transcript_mon.run())
|
|
189
|
+
|
|
190
|
+
async def send(self, text: str) -> None:
|
|
191
|
+
if not self._started:
|
|
192
|
+
raise ValueError("attach to a terminal before sending")
|
|
193
|
+
self.status = "working"
|
|
194
|
+
await asyncio.to_thread(self._poster, self._pid, text)
|
|
195
|
+
|
|
196
|
+
async def stop(self, *, detach_only: bool = False) -> None:
|
|
197
|
+
self._started = False
|
|
198
|
+
if self._transcript_mon is not None:
|
|
199
|
+
await self._transcript_mon.stop()
|
|
200
|
+
if self._monitor_task and not self._monitor_task.done():
|
|
201
|
+
self._monitor_task.cancel()
|
|
202
|
+
self.status = "idle"
|
server/billing.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Per-account minute balance + usage ledger for Voxa's metered V2V.
|
|
2
|
+
|
|
3
|
+
JSON-backed (like DeviceRegistry). The metered proxy debits seconds as a session
|
|
4
|
+
streams and refuses/cuts off when the balance hits zero; IAP purchases credit
|
|
5
|
+
minutes. Accounts are keyed by a stable per-customer id (e.g. the Apple
|
|
6
|
+
originalTransactionId, or a device id for the free trial).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
# Minutes granted to a brand-new account so people can try it before buying.
|
|
17
|
+
FREE_TRIAL_MINUTES = float(os.environ.get("VOXA_FREE_MINUTES", "5"))
|
|
18
|
+
|
|
19
|
+
# Minutes granted per StoreKit product id (subscription + consumable top-ups).
|
|
20
|
+
PRODUCT_MINUTES = {
|
|
21
|
+
"voxa.sub.starter": 200,
|
|
22
|
+
"voxa.sub.starter.yearly": 2400, # 200/mo equivalent, billed yearly
|
|
23
|
+
"voxa.topup.small": 50,
|
|
24
|
+
"voxa.topup.medium": 120,
|
|
25
|
+
"voxa.topup.large": 280,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Billing:
|
|
30
|
+
def __init__(self, path: str = "billing.json", free_minutes: float | None = None):
|
|
31
|
+
self._path = path
|
|
32
|
+
self._free = FREE_TRIAL_MINUTES if free_minutes is None else free_minutes
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._data: dict = self._load()
|
|
35
|
+
|
|
36
|
+
def _load(self) -> dict:
|
|
37
|
+
try:
|
|
38
|
+
with open(self._path) as f:
|
|
39
|
+
return json.load(f)
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
def _save(self) -> None:
|
|
44
|
+
tmp = f"{self._path}.tmp"
|
|
45
|
+
with open(tmp, "w") as f:
|
|
46
|
+
json.dump(self._data, f)
|
|
47
|
+
os.replace(tmp, self._path)
|
|
48
|
+
|
|
49
|
+
def _acct(self, account: str) -> dict:
|
|
50
|
+
a = self._data.get(account)
|
|
51
|
+
if a is None:
|
|
52
|
+
a = {"balance_sec": self._free * 60.0, "used_sec": 0.0,
|
|
53
|
+
"applied": [], "purchased_sec": 0.0}
|
|
54
|
+
self._data[account] = a
|
|
55
|
+
return a
|
|
56
|
+
|
|
57
|
+
# --- reads ---
|
|
58
|
+
def exists(self, account: str) -> bool:
|
|
59
|
+
"""True if the account already has a ledger record (does NOT create one).
|
|
60
|
+
Used to decide whether a request would MINT a new free-trial account, so
|
|
61
|
+
that creation can be rate-limited."""
|
|
62
|
+
with self._lock:
|
|
63
|
+
return account in self._data
|
|
64
|
+
|
|
65
|
+
def balance_minutes(self, account: str) -> float:
|
|
66
|
+
with self._lock:
|
|
67
|
+
return round(self._acct(account)["balance_sec"] / 60.0, 2)
|
|
68
|
+
|
|
69
|
+
def has_minutes(self, account: str) -> bool:
|
|
70
|
+
with self._lock:
|
|
71
|
+
return self._acct(account)["balance_sec"] > 0.0
|
|
72
|
+
|
|
73
|
+
# --- writes ---
|
|
74
|
+
def credit_minutes(self, account: str, minutes: float) -> float:
|
|
75
|
+
with self._lock:
|
|
76
|
+
a = self._acct(account)
|
|
77
|
+
a["balance_sec"] += minutes * 60.0
|
|
78
|
+
a["purchased_sec"] = a.get("purchased_sec", 0.0) + minutes * 60.0
|
|
79
|
+
self._save()
|
|
80
|
+
return round(a["balance_sec"] / 60.0, 2)
|
|
81
|
+
|
|
82
|
+
def apply_purchase(self, account: str, product_id: str, txn_id: str) -> float:
|
|
83
|
+
"""Credit the minutes for a verified purchase, idempotently (a given
|
|
84
|
+
transaction id is only ever applied once). Returns the new balance."""
|
|
85
|
+
with self._lock:
|
|
86
|
+
a = self._acct(account)
|
|
87
|
+
if txn_id and txn_id in a["applied"]:
|
|
88
|
+
return round(a["balance_sec"] / 60.0, 2)
|
|
89
|
+
minutes = PRODUCT_MINUTES.get(product_id, 0)
|
|
90
|
+
a["balance_sec"] += minutes * 60.0
|
|
91
|
+
a["purchased_sec"] = a.get("purchased_sec", 0.0) + minutes * 60.0
|
|
92
|
+
if txn_id:
|
|
93
|
+
a["applied"].append(txn_id)
|
|
94
|
+
self._save()
|
|
95
|
+
return round(a["balance_sec"] / 60.0, 2)
|
|
96
|
+
|
|
97
|
+
def delete_account(self, account: str) -> bool:
|
|
98
|
+
"""Remove an account's balance/ledger entirely (account deletion). Returns
|
|
99
|
+
True if there was anything to remove."""
|
|
100
|
+
with self._lock:
|
|
101
|
+
existed = self._data.pop(account, None) is not None
|
|
102
|
+
if existed:
|
|
103
|
+
self._save()
|
|
104
|
+
return existed
|
|
105
|
+
|
|
106
|
+
def debit_seconds(self, account: str, seconds: float) -> float:
|
|
107
|
+
"""Consume usage; returns remaining balance in minutes (floored at 0)."""
|
|
108
|
+
with self._lock:
|
|
109
|
+
a = self._acct(account)
|
|
110
|
+
a["balance_sec"] = max(0.0, a["balance_sec"] - seconds)
|
|
111
|
+
a["used_sec"] += seconds
|
|
112
|
+
self._save()
|
|
113
|
+
return round(a["balance_sec"] / 60.0, 2)
|
|
114
|
+
|
|
115
|
+
def merge_account(self, into_account: str, device_account: str) -> float:
|
|
116
|
+
"""Fold an anonymous device account's balance into a signed-in account, once.
|
|
117
|
+
Verified purchases carry in full; the FREE portion is capped so a user can't
|
|
118
|
+
stack multiple free trials onto one account. Idempotent: a device account is
|
|
119
|
+
marked merged and never contributes twice. Returns the target balance (min)."""
|
|
120
|
+
with self._lock:
|
|
121
|
+
into = self._acct(into_account)
|
|
122
|
+
if not device_account or device_account == into_account:
|
|
123
|
+
return round(into["balance_sec"] / 60.0, 2)
|
|
124
|
+
if not device_account.startswith("d-"):
|
|
125
|
+
return round(into["balance_sec"] / 60.0, 2)
|
|
126
|
+
dev = self._data.get(device_account)
|
|
127
|
+
if not dev or dev.get("merged"):
|
|
128
|
+
return round(into["balance_sec"] / 60.0, 2)
|
|
129
|
+
dev_balance = dev.get("balance_sec", 0.0)
|
|
130
|
+
dev_purchased = dev.get("purchased_sec", 0.0)
|
|
131
|
+
purchased_portion = min(dev_balance, dev_purchased)
|
|
132
|
+
free_portion = max(0.0, dev_balance - purchased_portion)
|
|
133
|
+
free_cap = max(0.0, self._free * 60.0 - into.get("merged_free_sec", 0.0))
|
|
134
|
+
free_credit = min(free_portion, free_cap)
|
|
135
|
+
credit = purchased_portion + free_credit
|
|
136
|
+
into["balance_sec"] += credit
|
|
137
|
+
into["purchased_sec"] = into.get("purchased_sec", 0.0) + purchased_portion
|
|
138
|
+
into["merged_free_sec"] = into.get("merged_free_sec", 0.0) + free_credit
|
|
139
|
+
dev["balance_sec"] = 0.0
|
|
140
|
+
dev["merged"] = into_account
|
|
141
|
+
self._save()
|
|
142
|
+
return round(into["balance_sec"] / 60.0, 2)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class MeteredSession:
|
|
146
|
+
"""Tracks billable seconds for an account while a V2V session streams, and
|
|
147
|
+
tells the proxy when to cut off. Used by the hosted metered proxy: call tick()
|
|
148
|
+
periodically while audio is flowing; flush() when the session ends."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, billing: Billing, account: str,
|
|
151
|
+
flush_every: float = 10.0, clock=time.monotonic):
|
|
152
|
+
self._b = billing
|
|
153
|
+
self._acct = account
|
|
154
|
+
self._flush_every = flush_every
|
|
155
|
+
self._clock = clock
|
|
156
|
+
self._last = clock()
|
|
157
|
+
self._accum = 0.0
|
|
158
|
+
|
|
159
|
+
def tick(self) -> float:
|
|
160
|
+
"""Accrue elapsed time; flush to the ledger in `flush_every` chunks.
|
|
161
|
+
Returns remaining minutes."""
|
|
162
|
+
now = self._clock()
|
|
163
|
+
self._accum += max(0.0, now - self._last)
|
|
164
|
+
self._last = now
|
|
165
|
+
if self._accum >= self._flush_every:
|
|
166
|
+
self._b.debit_seconds(self._acct, self._accum)
|
|
167
|
+
self._accum = 0.0
|
|
168
|
+
return self._b.balance_minutes(self._acct)
|
|
169
|
+
|
|
170
|
+
def flush(self) -> float:
|
|
171
|
+
if self._accum > 0:
|
|
172
|
+
self._b.debit_seconds(self._acct, self._accum)
|
|
173
|
+
self._accum = 0.0
|
|
174
|
+
return self._b.balance_minutes(self._acct)
|
|
175
|
+
|
|
176
|
+
def out_of_minutes(self) -> bool:
|
|
177
|
+
return not self._b.has_minutes(self._acct)
|
server/call_manager.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
# Bound the in-memory queues so a long-running process that never gets a phone
|
|
8
|
+
# connection (or a chatty decline stream) can't grow without limit.
|
|
9
|
+
_MAX_PENDING = 10
|
|
10
|
+
_MAX_DECLINED = 50
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CallManager:
|
|
14
|
+
def __init__(self, pusher, registry, ring_fn=None):
|
|
15
|
+
self._pusher = pusher
|
|
16
|
+
self._registry = registry
|
|
17
|
+
self._pending: list[str] = []
|
|
18
|
+
self._line_open = False
|
|
19
|
+
self._seq = 0
|
|
20
|
+
self._declined: list[str] = []
|
|
21
|
+
self._last_call_id: dict[str, str] = {} # account ("" = default) -> last call_id
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def line_open(self) -> bool:
|
|
25
|
+
return self._line_open
|
|
26
|
+
|
|
27
|
+
def attach(self) -> list[str]:
|
|
28
|
+
self._line_open = True
|
|
29
|
+
drained, self._pending = self._pending, []
|
|
30
|
+
return drained
|
|
31
|
+
|
|
32
|
+
def detach(self) -> None:
|
|
33
|
+
self._line_open = False
|
|
34
|
+
|
|
35
|
+
def queue(self, summary: str) -> None:
|
|
36
|
+
"""Queue an update to speak on the next attach WITHOUT ringing (the app is
|
|
37
|
+
open but no metered line is up yet)."""
|
|
38
|
+
self._pending.append(summary)
|
|
39
|
+
del self._pending[:-_MAX_PENDING]
|
|
40
|
+
|
|
41
|
+
async def decline(self, call_id: str) -> None:
|
|
42
|
+
"""The phone declined (or missed) this specific call. Record it (idempotent)
|
|
43
|
+
and cancel the matching ring on the account's other devices, so a call the
|
|
44
|
+
user rejected on one phone stops ringing everywhere."""
|
|
45
|
+
if not call_id or call_id in self._declined:
|
|
46
|
+
return
|
|
47
|
+
self._declined.append(call_id)
|
|
48
|
+
del self._declined[:-_MAX_DECLINED]
|
|
49
|
+
account = next((a for a, cid in self._last_call_id.items() if cid == call_id), None)
|
|
50
|
+
if account is not None:
|
|
51
|
+
await self.cancel(account)
|
|
52
|
+
|
|
53
|
+
async def on_update(self, summary: str) -> None:
|
|
54
|
+
if self._line_open:
|
|
55
|
+
return
|
|
56
|
+
self._pending.append(summary)
|
|
57
|
+
del self._pending[:-_MAX_PENDING]
|
|
58
|
+
await self.ring(None, summary)
|
|
59
|
+
|
|
60
|
+
async def ring(self, account: str | None, summary: str) -> None:
|
|
61
|
+
"""Ring a specific account's registered phones (or all if account is None).
|
|
62
|
+
Used by the cloud /notify path when a background terminal finishes and the
|
|
63
|
+
user isn't on the line."""
|
|
64
|
+
self._seq += 1
|
|
65
|
+
call_id = f"call-{self._seq}"
|
|
66
|
+
self._last_call_id[account or ""] = call_id
|
|
67
|
+
for token in self._registry.tokens(account):
|
|
68
|
+
try:
|
|
69
|
+
res = await self._pusher.send_voip(token, call_id, summary)
|
|
70
|
+
except Exception:
|
|
71
|
+
logger.exception("voip push raised for token %s", token[:8])
|
|
72
|
+
continue
|
|
73
|
+
if res is not True:
|
|
74
|
+
logger.warning("voip push rejected for token %s (call %s, status %s)",
|
|
75
|
+
token[:8], call_id, res)
|
|
76
|
+
# 410 Gone = the token is permanently dead (app deleted/reinstalled).
|
|
77
|
+
# Prune it so we stop ringing a phone that will never answer.
|
|
78
|
+
if res == 410 and hasattr(self._registry, "remove"):
|
|
79
|
+
self._registry.remove(token)
|
|
80
|
+
|
|
81
|
+
def last_call_id(self, account: str | None = None) -> str | None:
|
|
82
|
+
return self._last_call_id.get(account or "")
|
|
83
|
+
|
|
84
|
+
async def cancel(self, account: str | None = None) -> None:
|
|
85
|
+
"""Stop a ringing/active call on the account's phones (handled elsewhere, or no
|
|
86
|
+
longer relevant). No-op if nothing was rung for this account."""
|
|
87
|
+
call_id = self._last_call_id.pop(account or "", None)
|
|
88
|
+
if not call_id:
|
|
89
|
+
return
|
|
90
|
+
for token in self._registry.tokens(account):
|
|
91
|
+
await self._pusher.send_voip_cancel(token, call_id)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
|
|
3
|
+
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
|
|
4
|
+
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
|
|
5
|
+
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
|
|
6
|
+
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
|
|
7
|
+
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
|
|
8
|
+
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
|
|
9
|
+
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
|
|
10
|
+
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
|
|
11
|
+
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
|
|
12
|
+
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
|
|
13
|
+
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
|
|
14
|
+
6BgD56KyKA==
|
|
15
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
|
|
3
|
+
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
|
|
4
|
+
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
|
|
5
|
+
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
|
|
6
|
+
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
|
|
7
|
+
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
|
|
8
|
+
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
|
|
9
|
+
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
|
|
10
|
+
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
|
|
11
|
+
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
|
|
12
|
+
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
|
|
13
|
+
oyFraWVIyd/dganmrduC1bmTBGwD
|
|
14
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Awaitable, Callable, Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def text_from_assistant(message: object) -> Optional[str]:
|
|
15
|
+
content = getattr(message, "content", None)
|
|
16
|
+
if content is None:
|
|
17
|
+
return None
|
|
18
|
+
parts = [getattr(b, "text", None) for b in content]
|
|
19
|
+
parts = [p for p in parts if p]
|
|
20
|
+
return "".join(parts) if parts else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_watch(message: object) -> str:
|
|
24
|
+
"""Render a streamed Claude message as human-readable lines for the watch log.
|
|
25
|
+
|
|
26
|
+
Emits assistant text verbatim and a marker line for each tool use, so the
|
|
27
|
+
Terminal `tail -f` shows live progress as Claude works.
|
|
28
|
+
"""
|
|
29
|
+
content = getattr(message, "content", None)
|
|
30
|
+
if content is None:
|
|
31
|
+
return ""
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
for block in content:
|
|
34
|
+
text = getattr(block, "text", None)
|
|
35
|
+
tool_name = getattr(block, "name", None) # ToolUseBlock.name
|
|
36
|
+
if text:
|
|
37
|
+
out.append(text)
|
|
38
|
+
elif tool_name:
|
|
39
|
+
out.append(f"\n ⚙ {tool_name}")
|
|
40
|
+
return ("".join(out) + "\n") if out else ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_session_factory(working_dir: str):
|
|
44
|
+
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
|
|
45
|
+
opts = ClaudeAgentOptions(cwd=working_dir, permission_mode="bypassPermissions")
|
|
46
|
+
return ClaudeSDKClient(options=opts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
FinalCallback = Callable[[str], Awaitable[None]] | Callable[[str], None]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ClaudeController:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
session_factory: Optional[Callable[[str], object]] = None,
|
|
56
|
+
watch_log_path: Optional[str] = None,
|
|
57
|
+
launch_terminal: bool = False,
|
|
58
|
+
):
|
|
59
|
+
self._factory = session_factory or _default_session_factory
|
|
60
|
+
self.status = "idle"
|
|
61
|
+
self.working_dir: Optional[str] = None
|
|
62
|
+
self._final_cb: Optional[FinalCallback] = None
|
|
63
|
+
# One persistent Claude session per call, so multi-turn follow-ups keep
|
|
64
|
+
# context ("open it", "now add a test", ...).
|
|
65
|
+
self._client: Optional[object] = None
|
|
66
|
+
self._stack: Optional[contextlib.AsyncExitStack] = None
|
|
67
|
+
# Read-only "watch window": stream Claude's live output to a log file and
|
|
68
|
+
# (optionally, on macOS) open a Terminal tailing it.
|
|
69
|
+
self._watch_log_path = watch_log_path
|
|
70
|
+
self._launch_terminal = launch_terminal
|
|
71
|
+
self._watch_launched = False
|
|
72
|
+
|
|
73
|
+
def on_final(self, cb: FinalCallback) -> None:
|
|
74
|
+
self._final_cb = cb
|
|
75
|
+
|
|
76
|
+
async def start(self, working_dir: str) -> None:
|
|
77
|
+
path = os.path.abspath(os.path.expanduser(working_dir))
|
|
78
|
+
if not os.path.isdir(path):
|
|
79
|
+
raise ValueError(f"not a directory: {working_dir}")
|
|
80
|
+
# Switching directories closes the previous session (cwd is fixed per client).
|
|
81
|
+
await self._close_client()
|
|
82
|
+
self.working_dir = path
|
|
83
|
+
self.status = "idle"
|
|
84
|
+
self._open_watch(path)
|
|
85
|
+
self._stack = contextlib.AsyncExitStack()
|
|
86
|
+
self._client = await self._stack.enter_async_context(self._factory(path))
|
|
87
|
+
|
|
88
|
+
async def _close_client(self) -> None:
|
|
89
|
+
if self._stack is not None:
|
|
90
|
+
try:
|
|
91
|
+
await self._stack.aclose()
|
|
92
|
+
except Exception:
|
|
93
|
+
logger.exception("error closing Claude session")
|
|
94
|
+
self._stack = None
|
|
95
|
+
self._client = None
|
|
96
|
+
|
|
97
|
+
def _open_watch(self, working_dir: str) -> None:
|
|
98
|
+
if not self._watch_log_path or self._watch_launched:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
with open(self._watch_log_path, "w") as f:
|
|
102
|
+
f.write(f"Loop — watching Claude in {working_dir}\n")
|
|
103
|
+
f.write("(read-only live view; close this window any time)\n")
|
|
104
|
+
except OSError:
|
|
105
|
+
logger.exception("could not create watch log %s", self._watch_log_path)
|
|
106
|
+
return
|
|
107
|
+
if self._launch_terminal and sys.platform == "darwin":
|
|
108
|
+
script = f'tell application "Terminal" to do script "tail -f {self._watch_log_path}"'
|
|
109
|
+
try:
|
|
110
|
+
subprocess.Popen(
|
|
111
|
+
["osascript", "-e", script, "-e",
|
|
112
|
+
'tell application "Terminal" to activate']
|
|
113
|
+
)
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.exception("could not open watch Terminal window")
|
|
116
|
+
self._watch_launched = True
|
|
117
|
+
|
|
118
|
+
def _watch_write(self, text: str) -> None:
|
|
119
|
+
if not self._watch_log_path or not text:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
with open(self._watch_log_path, "a") as f:
|
|
123
|
+
f.write(text)
|
|
124
|
+
f.flush()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
async def send(self, text: str) -> None:
|
|
129
|
+
if self._client is None:
|
|
130
|
+
raise ValueError("call start() before send()")
|
|
131
|
+
self.status = "working"
|
|
132
|
+
final_text: Optional[str] = None
|
|
133
|
+
try:
|
|
134
|
+
self._watch_write(f"\n▶ {text}\n")
|
|
135
|
+
await self._client.query(text)
|
|
136
|
+
async for msg in self._client.receive_response():
|
|
137
|
+
t = text_from_assistant(msg)
|
|
138
|
+
if t:
|
|
139
|
+
final_text = t
|
|
140
|
+
self._watch_write(render_watch(msg))
|
|
141
|
+
self.status = "finished"
|
|
142
|
+
self._watch_write("\n✓ done\n")
|
|
143
|
+
except Exception:
|
|
144
|
+
self.status = "error"
|
|
145
|
+
self._watch_write("\n✗ error\n")
|
|
146
|
+
return
|
|
147
|
+
if final_text is not None and self._final_cb is not None:
|
|
148
|
+
result = self._final_cb(final_text)
|
|
149
|
+
if inspect.isawaitable(result):
|
|
150
|
+
await result
|
|
151
|
+
|
|
152
|
+
async def stop(self, *, detach_only: bool = False) -> None:
|
|
153
|
+
# detach_only is accepted for a uniform controller interface; the driven SDK
|
|
154
|
+
# session is closed either way (there is no separate terminal to leave running).
|
|
155
|
+
await self._close_client()
|
|
156
|
+
self.status = "idle"
|