voxa-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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"