callwalkietalkie 0.8.0

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,960 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ callwalkietalkie — phone chat into a cmux workspace.
4
+
5
+ Requires cmux running on this Mac. Pair over LAN with a QR code, then every
6
+ message goes through cmux's socket (surface.send_text / surface.read_text).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import argparse
13
+ import base64
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import logging
18
+ import os
19
+ import secrets
20
+ import signal
21
+ import socket
22
+ import subprocess
23
+ import sys
24
+ import threading
25
+ import time
26
+ import webbrowser
27
+
28
+ import qrcode
29
+ from flask import Flask, Response, abort, jsonify, request
30
+ from qrcode.constants import ERROR_CORRECT_H
31
+
32
+ # cmux adapter lives in a sibling folder (kept isolated on purpose).
33
+ _ADAPTER_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cmux-adapter")
34
+ if _ADAPTER_DIR not in sys.path:
35
+ sys.path.insert(0, _ADAPTER_DIR)
36
+ from cmux_adapter import ( # noqa: E402
37
+ CmuxError,
38
+ inventory,
39
+ open_session,
40
+ ping as cmux_ping,
41
+ )
42
+
43
+ app = Flask(__name__)
44
+ TOKEN = os.environ.get("LONGLEASH_TOKEN", "agnostic-dispatch")
45
+ PORT = int(os.environ.get("LONGLEASH_PORT", 8787))
46
+ LANDING_URL = os.environ.get(
47
+ "LONGLEASH_LANDING", "https://callwalkietalkie.com"
48
+ ).rstrip("/")
49
+ # Public relay the Mac dials out to. Set LONGLEASH_RELAY=off to stay LAN-only.
50
+ DEFAULT_RELAY = "https://callwalkietalkie.com"
51
+ RELAY_URL = (
52
+ os.environ.get("CALLWALKIETALKIE_RELAY")
53
+ or os.environ.get("LONGLEASH_RELAY")
54
+ or DEFAULT_RELAY
55
+ ).rstrip("/")
56
+
57
+ CAFFEINATE_SECONDS = 60 * 60
58
+ _caffeinate_seconds = CAFFEINATE_SECONDS
59
+ _caffeinate_proc = None
60
+ _caffeinate_atexit = False
61
+
62
+ _agent_lock = threading.Lock()
63
+ _session = None # cmux Session
64
+ _session_ws = None
65
+ _session_surface = None
66
+ _sent_by_surface: dict = {}
67
+ _notified = False
68
+
69
+ # Machine key from callwalkietalkie (LONGLEASH_KEY). Session is issued by the
70
+ # relay as a 6-digit HMAC handshake, or derived locally when relay is off.
71
+ MACHINE_KEY = (
72
+ os.environ.get("CALLWALKIETALKIE_KEY") or os.environ.get("LONGLEASH_KEY") or ""
73
+ ).strip()
74
+ SESSION_CODE = ""
75
+ KEY_FINGERPRINT = ""
76
+ _phone_arrived = False
77
+ _tunnel_lock = threading.Lock()
78
+ _tunnel_ws = None
79
+ _session_ready = threading.Event()
80
+
81
+
82
+ def _set_session(code: str, fingerprint: str = "") -> None:
83
+ global SESSION_CODE, KEY_FINGERPRINT
84
+ SESSION_CODE = str(code or "")
85
+ if fingerprint:
86
+ KEY_FINGERPRINT = fingerprint
87
+ if SESSION_CODE:
88
+ _session_ready.set()
89
+
90
+
91
+ def _lan_session_from_key(key: str) -> str:
92
+ """Same shape as relay sessions: 6 digits from HMAC(key)."""
93
+ digest = hmac.new(
94
+ b"callwalkietalkie-lan-v1", key.encode("utf-8"), hashlib.sha256
95
+ ).digest()
96
+ return f"{int.from_bytes(digest[:4], 'big') % 1_000_000:06d}"
97
+
98
+
99
+ def _bootstrap_session() -> None:
100
+ """LAN-only or pre-relay: derive a local 6-digit code from the machine key."""
101
+ if SESSION_CODE:
102
+ return
103
+ if MACHINE_KEY:
104
+ fp = hashlib.sha256(MACHINE_KEY.encode("utf-8")).hexdigest()[:8]
105
+ _set_session(_lan_session_from_key(MACHINE_KEY), fp)
106
+ else:
107
+ _set_session(f"{secrets.randbelow(1_000_000):06d}")
108
+
109
+
110
+ # ---------------------------------------------------------------- auth
111
+
112
+
113
+ @app.before_request
114
+ def check_token():
115
+ if request.path.startswith("/static"):
116
+ return
117
+ # Pairing landing is unguessable by session code, not by token.
118
+ if request.path.startswith("/p/"):
119
+ return
120
+ t = request.args.get("t") or request.headers.get("X-Longleash-Token")
121
+ if t != TOKEN:
122
+ abort(401)
123
+
124
+
125
+ # ---------------------------------------------------------------- cmux session
126
+
127
+
128
+ def _agent_error_message(exc):
129
+ if isinstance(exc, SystemExit):
130
+ msg = exc.code if isinstance(exc.code, str) else (exc.args[0] if exc.args else "cmux unavailable")
131
+ return str(msg)
132
+ return str(exc) or "cmux unavailable"
133
+
134
+
135
+ def _relay_enabled() -> bool:
136
+ return bool(RELAY_URL) and RELAY_URL.lower() not in {"off", "0", "false", "none"}
137
+
138
+
139
+ def _public_scan_url() -> str:
140
+ code = SESSION_CODE or "······"
141
+ if _relay_enabled():
142
+ return f"{RELAY_URL}/p/{code}"
143
+ return f"http://{lan_ip()}:{PORT}/p/{code}"
144
+
145
+
146
+ def _public_pair_url() -> str:
147
+ if _relay_enabled() and SESSION_CODE:
148
+ return f"{RELAY_URL}/pair/{SESSION_CODE}"
149
+ return f"http://127.0.0.1:{PORT}/setup?t={TOKEN}"
150
+
151
+
152
+ def _api_base() -> str:
153
+ prefix = (request.headers.get("X-Longleash-Prefix") or "").rstrip("/")
154
+ return f"{prefix}/agent" if prefix else "/agent"
155
+
156
+
157
+ def _tunnel_ws_url() -> str:
158
+ if RELAY_URL.startswith("https://"):
159
+ return "wss://" + RELAY_URL[len("https://") :] + "/host"
160
+ if RELAY_URL.startswith("http://"):
161
+ return "ws://" + RELAY_URL[len("http://") :] + "/host"
162
+ return RELAY_URL + "/host"
163
+
164
+
165
+ def _handle_tunnel_req(msg: dict) -> None:
166
+ global _tunnel_ws
167
+ from werkzeug.test import Client
168
+
169
+ client = Client(app)
170
+ path = msg.get("path") or "/"
171
+ query = msg.get("query") or ""
172
+ raw = base64.b64decode(msg.get("body") or "")
173
+ headers = {"X-Longleash-Prefix": msg.get("prefix") or ""}
174
+ extra = msg.get("headers") or {}
175
+ content_type = extra.get("content-type") or extra.get("Content-Type") or None
176
+ try:
177
+ resp = client.open(
178
+ path,
179
+ method=msg.get("method") or "GET",
180
+ query_string=query,
181
+ data=raw or None,
182
+ headers=headers,
183
+ content_type=content_type,
184
+ )
185
+ out_headers = {}
186
+ for key, value in resp.headers.items():
187
+ lk = key.lower()
188
+ if lk in {"content-type", "location", "cache-control", "pragma"}:
189
+ out_headers[lk] = value
190
+ payload = {
191
+ "type": "res",
192
+ "id": msg.get("id"),
193
+ "status": resp.status_code,
194
+ "headers": out_headers,
195
+ "body": base64.b64encode(resp.get_data() or b"").decode("ascii"),
196
+ }
197
+ except Exception as e:
198
+ payload = {
199
+ "type": "res",
200
+ "id": msg.get("id"),
201
+ "status": 500,
202
+ "headers": {"content-type": "application/json"},
203
+ "body": base64.b64encode(
204
+ json.dumps({"ok": False, "error": str(e)}).encode()
205
+ ).decode("ascii"),
206
+ }
207
+ data = json.dumps(payload)
208
+ with _tunnel_lock:
209
+ ws = _tunnel_ws
210
+ if ws is not None:
211
+ try:
212
+ ws.send(data)
213
+ except Exception:
214
+ pass
215
+
216
+
217
+ def _tunnel_loop() -> None:
218
+ global _tunnel_ws
219
+ try:
220
+ import websocket
221
+ except ImportError:
222
+ print(" relay skipped · pip install websocket-client")
223
+ return
224
+ if not MACHINE_KEY:
225
+ print(" relay skipped · missing key (run via npx callwalkietalkie)")
226
+ return
227
+ backoff = 1.0
228
+ url = _tunnel_ws_url()
229
+ while True:
230
+ ws = None
231
+ try:
232
+ ws = websocket.create_connection(url, timeout=30)
233
+ with _tunnel_lock:
234
+ _tunnel_ws = ws
235
+ ws.send(json.dumps({"type": "hello", "key": MACHINE_KEY}))
236
+ raw = ws.recv()
237
+ if not raw:
238
+ raise RuntimeError("empty hello reply")
239
+ hello = json.loads(raw)
240
+ if hello.get("type") != "ok" or not hello.get("session"):
241
+ err = hello.get("error") or "bad hello"
242
+ print(f" relay rejected · {err}")
243
+ raise RuntimeError(err)
244
+ _set_session(str(hello["session"]), str(hello.get("fingerprint") or ""))
245
+ backoff = 1.0
246
+ print(f" relay live · session {SESSION_CODE} · {_public_scan_url()}")
247
+ while True:
248
+ raw = ws.recv()
249
+ if not raw:
250
+ break
251
+ msg = json.loads(raw)
252
+ if msg.get("type") == "req":
253
+ threading.Thread(
254
+ target=_handle_tunnel_req, args=(msg,), daemon=True
255
+ ).start()
256
+ except Exception:
257
+ pass
258
+ with _tunnel_lock:
259
+ if _tunnel_ws is ws:
260
+ _tunnel_ws = None
261
+ try:
262
+ if ws is not None:
263
+ ws.close()
264
+ except Exception:
265
+ pass
266
+ time.sleep(backoff)
267
+ backoff = min(backoff * 2, 20.0)
268
+
269
+
270
+ def _get_session(workspace=None, surface=None, window=None, *, force=False):
271
+ global _session, _session_ws, _session_surface, _notified
272
+ want_ws = workspace if workspace is not None else _session_ws
273
+ want_sf = surface if surface is not None else _session_surface
274
+ # Persist phone-echoed sends per surface before swapping sessions.
275
+ if _session is not None and _session_surface:
276
+ _sent_by_surface[_session_surface] = list(_session._sent)
277
+ fresh = (
278
+ force
279
+ or _session is None
280
+ or (want_ws and want_ws != _session_ws)
281
+ or (want_sf and want_sf != _session_surface)
282
+ )
283
+ if fresh:
284
+ _session = open_session(workspace=want_ws, surface=want_sf, window=window)
285
+ _session_ws = _session.target.workspace_ref
286
+ _session_surface = _session.target.surface_ref
287
+ _session._sent = list(_sent_by_surface.get(_session_surface, []))
288
+ if not _notified:
289
+ _session.notify_linked()
290
+ _notified = True
291
+ else:
292
+ try:
293
+ _session.refresh_target_meta()
294
+ except CmuxError:
295
+ pass
296
+ return _session
297
+
298
+
299
+ # ---------------------------------------------------------------- setup UI
300
+
301
+ PAPER_CSS = """
302
+ :root {
303
+ --canvas:#ffffff; --soft:#f7f6f3; --ink:#37352f;
304
+ --muted:#787774; --faint:#9b9a97;
305
+ --line:rgba(55,53,47,0.09); --line-strong:rgba(55,53,47,0.16);
306
+ --action:#2383e2; --cta:#0075de; --cta-hover:#005bab; --ok:#448361;
307
+ --code-bg:#f9f8f7;
308
+ --mono:"SFMono-Regular", Menlo, Consolas, "Liberation Mono", Courier, monospace;
309
+ }
310
+ * { box-sizing:border-box; margin:0; padding:0; }
311
+ [hidden] { display:none !important; }
312
+ html, body { height:100%; }
313
+ body {
314
+ min-height:100%; display:flex; flex-direction:column;
315
+ font-family:Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
316
+ background:var(--canvas); color:var(--ink);
317
+ font-size:14px; line-height:1.55; -webkit-font-smoothing:antialiased;
318
+ }
319
+ .top {
320
+ flex:none; display:flex; align-items:center; justify-content:space-between; gap:16px;
321
+ padding:13px 28px; border-bottom:1px solid var(--line);
322
+ }
323
+ .brand {
324
+ display:inline-flex; align-items:center; gap:7px;
325
+ font-size:15px; font-weight:600; color:var(--ink);
326
+ }
327
+ .brand .lasso { flex:none; color:#000; }
328
+ .meta { font-size:12px; color:var(--muted); }
329
+ .stage {
330
+ flex:1; display:flex; align-items:center; justify-content:center;
331
+ width:100%; padding:24px 0;
332
+ }
333
+ main { width:100%; max-width:640px; margin:0 auto; padding:0 28px; }
334
+ h1 { font-size:22px; font-weight:600; letter-spacing:-0.02em; line-height:1.2; }
335
+ .steps { list-style:none; }
336
+ .step { position:relative; display:flex; gap:14px; padding-bottom:30px; }
337
+ .step:last-child { padding-bottom:0; }
338
+ .rail { flex:none; width:18px; }
339
+ .wire {
340
+ position:absolute; top:23px; bottom:2px; left:8px; width:2px;
341
+ border-radius:1px; background:var(--line); overflow:hidden;
342
+ }
343
+ .wire span {
344
+ display:block; width:100%; height:0;
345
+ background:var(--ink); transition:height 300ms ease;
346
+ }
347
+ .box {
348
+ flex:none; width:18px; height:18px; margin-top:1px;
349
+ border:1px solid var(--line-strong); border-radius:4px;
350
+ display:flex; align-items:center; justify-content:center;
351
+ transition:background 150ms ease, border-color 150ms ease;
352
+ }
353
+ .box svg { opacity:0; transition:opacity 150ms ease; }
354
+ .step[data-state="done"] .box, .box.done { background:var(--ink); border-color:var(--ink); }
355
+ .step[data-state="done"] .box svg, .box.done svg { opacity:1; }
356
+ .step-body { flex:1; min-width:0; }
357
+ .step-head { display:flex; align-items:baseline; justify-content:space-between; gap:16px; }
358
+ .eyebrow {
359
+ font-size:11px; font-weight:600; letter-spacing:0.06em;
360
+ text-transform:uppercase; color:var(--faint);
361
+ }
362
+ .step[data-state="done"] .eyebrow { color:var(--ok); }
363
+ .label { font-size:16px; font-weight:600; letter-spacing:-0.01em; }
364
+ .status {
365
+ flex:none; display:flex; align-items:center; gap:6px;
366
+ font-size:12px; color:var(--faint); font-variant-numeric:tabular-nums;
367
+ }
368
+ .status .dot { width:6px; height:6px; border-radius:50%; background:currentColor; }
369
+ .step[data-state="waiting"] .status .dot { animation:pulse 1.6s linear infinite; }
370
+ .step[data-state="done"] .status { color:var(--ok); }
371
+ .step[data-state="done"] .status .dot { animation:none; }
372
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }
373
+ .hint { margin-top:6px; color:var(--muted); }
374
+ .qr {
375
+ display:flex; align-items:center; gap:16px; margin-top:12px; padding:16px;
376
+ border:1px solid var(--line-strong); border-radius:8px;
377
+ }
378
+ .qr img { flex:none; display:block; }
379
+ .qr-aside { min-width:0; }
380
+ .qr-aside p { color:var(--muted); }
381
+ .session { margin-bottom:12px; }
382
+ .session strong {
383
+ font-family:var(--mono); font-size:15px; font-weight:500;
384
+ letter-spacing:0.04em; color:var(--ink);
385
+ }
386
+ .qr-url {
387
+ display:inline-block; margin-top:6px; max-width:100%;
388
+ overflow-wrap:anywhere; color:var(--action); text-decoration:none;
389
+ }
390
+ .qr-url:hover { color:var(--cta-hover); text-decoration:underline; }
391
+ .countdown { color:var(--ink); font-weight:600; font-variant-numeric:tabular-nums; }
392
+ .centered { max-width:380px; margin:0 auto; padding:0 28px; text-align:center; }
393
+ .centered .box { margin:0 auto 16px; width:28px; height:28px; }
394
+ .centered .box svg { transform:scale(1.4); }
395
+ .centered p { color:var(--muted); margin-top:8px; }
396
+ @media (max-width:420px) {
397
+ main, .centered { padding-left:18px; padding-right:18px; }
398
+ .top { padding-left:18px; padding-right:18px; }
399
+ .stage { align-items:flex-start; padding-top:28px; }
400
+ .qr { flex-direction:column; align-items:flex-start; }
401
+ }
402
+ """
403
+
404
+ _CHECK_SVG = """<svg width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">
405
+ <path d="M1.5 5.8 4 8.3 9.5 2.8" stroke="#fff" stroke-width="1.8"
406
+ stroke-linecap="round" stroke-linejoin="round"/></svg>"""
407
+
408
+ _LASSO_SVG = """<svg class="lasso" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
409
+ <ellipse cx="10.5" cy="10" rx="7" ry="6.5" stroke="currentColor" stroke-width="1.85"/>
410
+ <path d="M16.2 13.8c1.6 1.4 3.4 3.8 4.6 6.4" stroke="currentColor" stroke-width="1.85" stroke-linecap="round"/>
411
+ </svg>"""
412
+
413
+ _BRAND = f'<span class="brand">{_LASSO_SVG}Call Walkie Talkie</span>'
414
+
415
+ SETUP_HTML = """<!doctype html>
416
+ <html lang="en"><head>
417
+ <meta charset="utf-8">
418
+ <meta name="viewport" content="width=device-width, initial-scale=1">
419
+ <title>Link your phone — Call Walkie Talkie</title>
420
+ <link rel="preconnect" href="https://fonts.googleapis.com">
421
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
422
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
423
+ <style>__CSS__</style>
424
+ </head><body>
425
+ <div class="top">__BRAND__<span class="meta">Setup · cmux</span></div>
426
+ <div class="stage">
427
+ <main>
428
+ <ol class="steps">
429
+ <li class="step" id="step-computer" data-state="done">
430
+ <div class="rail">
431
+ <span class="box">__CHECK__</span>
432
+ <span class="wire" aria-hidden="true"><span id="wire-fill" style="height:50%"></span></span>
433
+ </div>
434
+ <div class="step-body">
435
+ <div class="step-head">
436
+ <div>
437
+ <p class="eyebrow">Step 1/2</p>
438
+ <h2 class="label">Link your computer</h2>
439
+ </div>
440
+ <div class="status"><span class="dot"></span><span>__HOSTNAME__</span></div>
441
+ </div>
442
+ </div>
443
+ </li>
444
+
445
+ <li class="step" id="step-phone" data-state="waiting">
446
+ <div class="rail"><span class="box">__CHECK__</span></div>
447
+ <div class="step-body">
448
+ <div class="step-head">
449
+ <div>
450
+ <p class="eyebrow">Step 2/2</p>
451
+ <h2 class="label">Link your phone</h2>
452
+ </div>
453
+ <div class="status"><span class="dot"></span><span id="meta-phone">Waiting for a scan</span></div>
454
+ </div>
455
+ <div class="qr" id="qr-block">
456
+ __QR__
457
+ <div class="qr-aside">
458
+ <p class="session">Session <strong>__CODE__</strong></p>
459
+ <p>If it won't scan, open this on your phone instead:</p>
460
+ <a class="qr-url" href="__SCAN_URL__">__SCAN_URL__</a>
461
+ </div>
462
+ </div>
463
+ <p class="hint" id="done-detail" hidden></p>
464
+ </div>
465
+ </li>
466
+ </ol>
467
+ </main>
468
+ </div>
469
+ <script>
470
+ (function() {
471
+ var TOKEN = "__TOKEN__";
472
+ var CHAT = "/agent?t=" + encodeURIComponent(TOKEN);
473
+ var SECONDS = 3;
474
+ var stepPhone = document.getElementById("step-phone");
475
+ var metaPhone = document.getElementById("meta-phone");
476
+ var wireFill = document.getElementById("wire-fill");
477
+ var qrBlock = document.getElementById("qr-block");
478
+ var doneDetail = document.getElementById("done-detail");
479
+ var timer = null;
480
+
481
+ function arrive() {
482
+ clearInterval(timer);
483
+ stepPhone.dataset.state = "done";
484
+ metaPhone.textContent = "Connected";
485
+ wireFill.style.height = "100%";
486
+ qrBlock.hidden = true;
487
+ doneDetail.hidden = false;
488
+
489
+ var count = document.createElement("span");
490
+ count.className = "countdown";
491
+ count.textContent = SECONDS;
492
+ var now = document.createElement("a");
493
+ now.className = "qr-url";
494
+ now.href = CHAT;
495
+ now.textContent = "Open it now";
496
+ doneDetail.textContent = "Opening the chat here too in ";
497
+ doneDetail.append(count, document.createTextNode(" \\u00b7 "), now);
498
+
499
+ var left = SECONDS;
500
+ var ticker = setInterval(function() {
501
+ left -= 1;
502
+ if (left <= 0) {
503
+ clearInterval(ticker);
504
+ window.location.assign(CHAT);
505
+ return;
506
+ }
507
+ count.textContent = left;
508
+ }, 1000);
509
+ }
510
+
511
+ async function poll() {
512
+ try {
513
+ var r = await fetch("/setup/state?t=" + encodeURIComponent(TOKEN));
514
+ var s = await r.json();
515
+ if (s.paired) arrive();
516
+ } catch (e) {}
517
+ }
518
+
519
+ poll();
520
+ timer = setInterval(poll, 1200);
521
+ })();
522
+ </script>
523
+ </body></html>"""
524
+
525
+ HANDOFF_HTML = """<!doctype html>
526
+ <html lang="en"><head>
527
+ <meta charset="utf-8">
528
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
529
+ <title>Phone linked — Call Walkie Talkie</title>
530
+ <link rel="preconnect" href="https://fonts.googleapis.com">
531
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
532
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
533
+ <style>__CSS__</style>
534
+ </head><body>
535
+ <div class="top">__BRAND__<span class="meta">Setup</span></div>
536
+ <div class="stage">
537
+ <div class="centered">
538
+ <span class="box done">__CHECK__</span>
539
+ <h1>Phone linked</h1>
540
+ <p>Opening cmux chat…</p>
541
+ </div>
542
+ </div>
543
+ <script>setTimeout(function(){ location.replace("__CHAT__"); }, 600);</script>
544
+ </body></html>"""
545
+
546
+ STALE_HTML = """<!doctype html>
547
+ <html lang="en"><head>
548
+ <meta charset="utf-8">
549
+ <meta name="viewport" content="width=device-width, initial-scale=1">
550
+ <title>Session expired — Call Walkie Talkie</title>
551
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
552
+ <style>__CSS__</style>
553
+ </head><body>
554
+ <div class="top">__BRAND__<span class="meta">Setup</span></div>
555
+ <div class="stage">
556
+ <div class="centered">
557
+ <h1>Session expired</h1>
558
+ <p>Run <code>npx callwalkietalkie</code> on your Mac and scan again.</p>
559
+ </div>
560
+ </div>
561
+ </body></html>"""
562
+
563
+
564
+ def _hostname():
565
+ return socket.gethostname().replace(".local", "")
566
+
567
+
568
+ def _fill(template, **values):
569
+ out = template
570
+ for key, val in values.items():
571
+ out = out.replace(f"__{key}__", str(val))
572
+ return out
573
+
574
+
575
+ def _qr_svg_data_uri(target_url, module_px=6, fill="#12202a", radius=0):
576
+ """Real, scannable QR for target_url as an inline SVG data URI."""
577
+ qr = qrcode.QRCode(border=2, error_correction=ERROR_CORRECT_H)
578
+ qr.add_data(target_url)
579
+ qr.make(fit=True)
580
+ matrix = qr.get_matrix()
581
+ n = len(matrix)
582
+ size = n * module_px
583
+ rx = f' rx="{radius}"' if radius else ""
584
+ rects = "".join(
585
+ f'<rect x="{x * module_px}" y="{y * module_px}" '
586
+ f'width="{module_px}" height="{module_px}"{rx}/>'
587
+ for y, row in enumerate(matrix)
588
+ for x, on in enumerate(row)
589
+ if on
590
+ )
591
+ svg = (
592
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" '
593
+ f'viewBox="0 0 {size} {size}" shape-rendering="crispEdges">'
594
+ f'<rect width="{size}" height="{size}" fill="#fff"/>'
595
+ f'<g fill="{fill}">{rects}</g>'
596
+ f'</svg>'
597
+ )
598
+ return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode()
599
+
600
+
601
+ def lan_ip():
602
+ try:
603
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
604
+ s.connect(("8.8.8.8", 80))
605
+ ip = s.getsockname()[0]
606
+ s.close()
607
+ return ip
608
+ except Exception:
609
+ return "127.0.0.1"
610
+
611
+
612
+ @app.route("/")
613
+ def root():
614
+ return Response(
615
+ f'<meta http-equiv="refresh" content="0;url=/setup?t={TOKEN}">',
616
+ mimetype="text/html",
617
+ )
618
+
619
+
620
+ @app.route("/setup")
621
+ def setup_page():
622
+ scan_url = _public_scan_url()
623
+ qr = f'<img src="{_qr_svg_data_uri(scan_url)}" width="148" height="148" alt="QR code">'
624
+ return _fill(
625
+ SETUP_HTML,
626
+ CSS=PAPER_CSS,
627
+ BRAND=_BRAND,
628
+ CHECK=_CHECK_SVG,
629
+ HOSTNAME=_hostname(),
630
+ TOKEN=TOKEN,
631
+ CODE=SESSION_CODE,
632
+ SCAN_URL=scan_url,
633
+ QR=qr,
634
+ )
635
+
636
+
637
+ @app.route("/setup/state")
638
+ def setup_state():
639
+ ready = bool(SESSION_CODE) and (
640
+ not _relay_enabled() or _session_ready.is_set()
641
+ )
642
+ return jsonify(
643
+ {
644
+ "paired": _phone_arrived,
645
+ "code": SESSION_CODE or None,
646
+ "ready": ready,
647
+ "fingerprint": KEY_FINGERPRINT or None,
648
+ "pair_url": _public_pair_url() if SESSION_CODE else None,
649
+ }
650
+ )
651
+
652
+
653
+ @app.route("/p/<code>")
654
+ def phone_landed(code):
655
+ """The listener. Reaching this route *is* the proof the phone scanned it."""
656
+ global _phone_arrived
657
+ if code != SESSION_CODE:
658
+ return _fill(STALE_HTML, CSS=PAPER_CSS, BRAND=_BRAND), 404
659
+ _phone_arrived = True
660
+ return _fill(
661
+ HANDOFF_HTML,
662
+ CSS=PAPER_CSS,
663
+ BRAND=_BRAND,
664
+ CHECK=_CHECK_SVG,
665
+ CHAT=f"{_api_base()}?t={TOKEN}",
666
+ )
667
+
668
+
669
+ # ---------------------------------------------------------------- agent (cmux chat)
670
+
671
+
672
+ @app.route("/agent")
673
+ def agent_page():
674
+ ui_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "agent_ui.html")
675
+ html = open(ui_path, encoding="utf-8").read()
676
+ html = html.replace("__TOKEN_JSON__", json.dumps(TOKEN))
677
+ html = html.replace("__LANDING_JSON__", json.dumps(LANDING_URL))
678
+ html = html.replace("__API_BASE_JSON__", json.dumps(_api_base()))
679
+ return Response(
680
+ html,
681
+ mimetype="text/html",
682
+ headers={
683
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
684
+ "Pragma": "no-cache",
685
+ },
686
+ )
687
+
688
+
689
+ @app.route("/agent/status")
690
+ def agent_status():
691
+ try:
692
+ with _agent_lock:
693
+ session = _get_session()
694
+ session.refresh_target_meta()
695
+ t = session.target
696
+ sessions = []
697
+ try:
698
+ sessions = inventory(socket_path=session.socket_path)
699
+ except CmuxError:
700
+ pass
701
+ payload = {
702
+ "ok": True,
703
+ "app": "cmux",
704
+ "title": t.title,
705
+ "cwd": t.cwd,
706
+ "workspace": t.workspace_ref,
707
+ "surface": t.surface_ref,
708
+ "window": t.window_ref,
709
+ "socket": session.socket_path,
710
+ "sessions": sessions,
711
+ # Back-compat for older phone UI
712
+ "workspaces": [
713
+ {
714
+ "id": s.get("workspace_id"),
715
+ "ref": s.get("workspace"),
716
+ "title": s.get("title"),
717
+ "custom_title": None,
718
+ "current_directory": s.get("cwd"),
719
+ "selected": s.get("active"),
720
+ "surface": s.get("surface"),
721
+ }
722
+ for s in sessions
723
+ ],
724
+ }
725
+ return jsonify(payload)
726
+ except KeyboardInterrupt:
727
+ raise
728
+ except BaseException as e:
729
+ return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
730
+
731
+
732
+ @app.route("/agent/select", methods=["POST"])
733
+ def agent_select():
734
+ body = request.get_json(force=True, silent=True) or {}
735
+ workspace = (body.get("workspace") or "").strip() or None
736
+ surface = (body.get("surface") or "").strip() or None
737
+ window = (body.get("window") or "").strip() or None
738
+ try:
739
+ with _agent_lock:
740
+ session = _get_session(
741
+ workspace=workspace, surface=surface, window=window, force=True
742
+ )
743
+ session.focus()
744
+ t = session.target
745
+ return jsonify(
746
+ {
747
+ "ok": True,
748
+ "workspace": t.workspace_ref,
749
+ "surface": t.surface_ref,
750
+ "window": t.window_ref,
751
+ "title": t.title,
752
+ "cwd": t.cwd,
753
+ }
754
+ )
755
+ except KeyboardInterrupt:
756
+ raise
757
+ except BaseException as e:
758
+ return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
759
+
760
+
761
+ @app.route("/agent/transcript")
762
+ def agent_transcript():
763
+ try:
764
+ with _agent_lock:
765
+ session = _get_session()
766
+ msgs = session.transcript()
767
+ payload = {
768
+ "ok": True,
769
+ "busy": session.busy(),
770
+ "messages": [
771
+ {"role": m.role, "text": m.text, "kind": m.kind} for m in msgs
772
+ ],
773
+ }
774
+ return jsonify(payload)
775
+ except KeyboardInterrupt:
776
+ raise
777
+ except BaseException as e:
778
+ return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
779
+
780
+
781
+ @app.route("/agent/send", methods=["POST"])
782
+ def agent_send():
783
+ body = request.get_json(force=True, silent=True) or {}
784
+ text = (body.get("text") or "").strip()
785
+ if not text:
786
+ return jsonify({"ok": False, "error": "empty message"}), 400
787
+ try:
788
+ with _agent_lock:
789
+ session = _get_session()
790
+ session.send(text)
791
+ if _session_surface:
792
+ _sent_by_surface[_session_surface] = list(session._sent)
793
+ return jsonify({"ok": True})
794
+ except KeyboardInterrupt:
795
+ raise
796
+ except BaseException as e:
797
+ return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
798
+
799
+
800
+ @app.route("/agent/interrupt", methods=["POST"])
801
+ def agent_interrupt():
802
+ try:
803
+ with _agent_lock:
804
+ session = _get_session()
805
+ session.interrupt()
806
+ return jsonify({"ok": True})
807
+ except KeyboardInterrupt:
808
+ raise
809
+ except BaseException as e:
810
+ return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
811
+
812
+
813
+ def _caffeinate_active():
814
+ return _caffeinate_proc is not None and _caffeinate_proc.poll() is None
815
+
816
+
817
+ def _stop_caffeinate():
818
+ global _caffeinate_proc
819
+ proc = _caffeinate_proc
820
+ _caffeinate_proc = None
821
+ if proc is None or proc.poll() is not None:
822
+ return
823
+ try:
824
+ proc.terminate()
825
+ proc.wait(timeout=1)
826
+ except Exception:
827
+ try:
828
+ proc.kill()
829
+ except Exception:
830
+ pass
831
+
832
+
833
+ def _start_caffeinate(seconds=None):
834
+ global _caffeinate_proc, _caffeinate_seconds, _caffeinate_atexit
835
+ if seconds is not None:
836
+ _caffeinate_seconds = int(seconds)
837
+ if _caffeinate_seconds <= 0:
838
+ _stop_caffeinate()
839
+ return False
840
+ _stop_caffeinate()
841
+ try:
842
+ _caffeinate_proc = subprocess.Popen(
843
+ ["caffeinate", "-dims", "-t", str(_caffeinate_seconds)],
844
+ stdout=subprocess.DEVNULL,
845
+ stderr=subprocess.DEVNULL,
846
+ )
847
+ except FileNotFoundError:
848
+ _caffeinate_proc = None
849
+ return False
850
+ if not _caffeinate_atexit:
851
+ atexit.register(_stop_caffeinate)
852
+ _caffeinate_atexit = True
853
+ return True
854
+
855
+
856
+ @app.route("/agent/caffeinate", methods=["GET", "POST"])
857
+ def agent_caffeinate():
858
+ if request.method == "POST":
859
+ body = request.get_json(force=True, silent=True) or {}
860
+ want = body.get("on")
861
+ if want is None:
862
+ want = not _caffeinate_active()
863
+ if want:
864
+ ok = _start_caffeinate()
865
+ if not ok and _caffeinate_seconds > 0:
866
+ return jsonify(ok=False, on=False, error="caffeinate unavailable"), 503
867
+ else:
868
+ _stop_caffeinate()
869
+ return jsonify(ok=True, on=_caffeinate_active(), seconds=_caffeinate_seconds)
870
+
871
+
872
+ @app.route("/agent/shutdown", methods=["POST"])
873
+ def agent_shutdown():
874
+ def _die():
875
+ time.sleep(0.6)
876
+ _stop_caffeinate()
877
+ os._exit(0)
878
+
879
+ threading.Thread(target=_die, daemon=True).start()
880
+ return jsonify({"ok": True})
881
+
882
+
883
+ # ---------------------------------------------------------------- boot
884
+
885
+
886
+ def _terminal_hyperlink(text, url):
887
+ return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
888
+
889
+
890
+ def _open_browser_later(url, delay=0.75):
891
+ def _go():
892
+ time.sleep(delay)
893
+ try:
894
+ webbrowser.open(url)
895
+ except Exception:
896
+ pass
897
+
898
+ threading.Thread(target=_go, daemon=True).start()
899
+
900
+
901
+ def main():
902
+ parser = argparse.ArgumentParser(description="callwalkietalkie — phone ↔ cmux")
903
+ parser.add_argument("--no-open", action="store_true")
904
+ parser.add_argument("--port", type=int, default=PORT)
905
+ args = parser.parse_args()
906
+ port = args.port
907
+
908
+ # Fail fast with a clear message if cmux isn't up.
909
+ try:
910
+ info = cmux_ping()
911
+ print(f" cmux ok · {info['socket_path']}")
912
+ except CmuxError as e:
913
+ print(f"\n {e}\n", file=sys.stderr)
914
+ sys.exit(1)
915
+
916
+ setup_url = f"http://127.0.0.1:{port}/setup?t={TOKEN}"
917
+
918
+ if _relay_enabled():
919
+ if not MACHINE_KEY:
920
+ print(
921
+ "\n Missing machine key. Run via: npx callwalkietalkie\n",
922
+ file=sys.stderr,
923
+ )
924
+ sys.exit(1)
925
+ threading.Thread(target=_tunnel_loop, daemon=True).start()
926
+ if not _session_ready.wait(timeout=25):
927
+ print(
928
+ "\n Relay did not issue a session. Check callwalkietalkie.com / network.\n",
929
+ file=sys.stderr,
930
+ )
931
+ sys.exit(1)
932
+ else:
933
+ _bootstrap_session()
934
+
935
+ pair_url = _public_pair_url()
936
+ phone_hint = _public_scan_url()
937
+
938
+ print(f" Scan the QR here: {_terminal_hyperlink(pair_url, pair_url)}")
939
+ print(f" Phone path: {phone_hint}")
940
+ print(f" Session {SESSION_CODE}")
941
+ if KEY_FINGERPRINT:
942
+ print(f" Key {KEY_FINGERPRINT}")
943
+
944
+ def _on_signal(signum, frame):
945
+ _stop_caffeinate()
946
+ sys.exit(0)
947
+
948
+ signal.signal(signal.SIGINT, _on_signal)
949
+ signal.signal(signal.SIGTERM, _on_signal)
950
+
951
+ open_url = pair_url if _relay_enabled() else setup_url
952
+ if not args.no_open:
953
+ _open_browser_later(open_url)
954
+
955
+ logging.getLogger("werkzeug").setLevel(logging.WARNING)
956
+ app.run(host="0.0.0.0", port=port, threaded=True, use_reloader=False)
957
+
958
+
959
+ if __name__ == "__main__":
960
+ main()