elvarone-bridge 0.1.0__tar.gz

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,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: elvarone-bridge
3
+ Version: 0.1.0
4
+ Summary: ElvarOne compute bridge — an MCP server that lets the phone agent run code, take screenshots and leave reports on your computer, and lets your desktop coding agent (Claude Code, Codex) answer them.
5
+ Author: ElvarOne
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://agoradigest.com/dm
8
+ Project-URL: Source, https://github.com/shichuanqiong/ElvarAgent
9
+ Keywords: mcp,elvarone,agent,bridge,claude-code,codex,screenshot
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: No Input/Output (Daemon)
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: mcp>=1.10
21
+ Requires-Dist: starlette>=0.40
22
+ Requires-Dist: uvicorn>=0.30
23
+ Requires-Dist: httpx>=0.27
24
+ Provides-Extra: agoradm
25
+ Requires-Dist: agoradm>=0.13.0; extra == "agoradm"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+
29
+ # elvarone-bridge
30
+
31
+ The ElvarOne phone agent's compute bridge: a small MCP server on your
32
+ computer that lets the agent run Python and shell commands, take
33
+ screenshots it can actually look at, publish files, and leave written
34
+ reports that the coding agent on your computer (Claude Code or Codex)
35
+ answers automatically.
36
+
37
+ ```
38
+ pip install elvarone-bridge
39
+ elvarone-bridge init
40
+ ```
41
+
42
+ `init` mints a token, writes `~/ElvarBridge/config.env`, installs a
43
+ background service (launchd on macOS, a systemd user unit on Linux, a
44
+ Task Scheduler command on Windows) and prints the URL + token to enter
45
+ on the phone: **Settings → AI Brain → MCP Servers → Add**.
46
+
47
+ ## Let your desktop AI set it up
48
+
49
+ Paste this to Claude Code, Codex, Cursor or any coding agent on the
50
+ computer you want to connect:
51
+
52
+ > Install the ElvarOne compute bridge on this computer: `pip install elvarone-bridge` (use a venv or pipx if you prefer), then run `elvarone-bridge init --agent claude --project <the project you want me to answer questions about>`. Show me the "Phone settings" block it prints (URL and token) so I can enter them in the ElvarOne app. Then run `elvarone-bridge doctor` and fix anything it flags. Add this line to your project instructions (CLAUDE.md or AGENTS.md): "At the start of each session, list ~/ElvarBridge/reports/*.md and answer any file with status: unread; write replies as <stamp>-reply-<slug>.md in the same folder and set the report's status to read."
53
+
54
+ Replace `--agent claude` with `--agent codex` for Codex, or `--agent none`
55
+ to keep reports for a human session only.
56
+
57
+ ## What the phone agent gets
58
+
59
+ | tool | what it does |
60
+ |---|---|
61
+ | `run_shell`, `run_python`, `read_file` | compute and files inside `~/ElvarBridge` (the shell is human-approved on the phone for every call) |
62
+ | `screenshot(app, max_width, delay_s)` | capture the screen, downscale, return a **signed, expiring URL** the phone's `view_image` reads — no token in the link |
63
+ | `publish_file(path)` | same signed link for any file in the workdir |
64
+ | `report_to_desktop_agent(title, body, kind, from_agent)` | file a bug / request / finding / question in `~/ElvarBridge/reports/` |
65
+ | `list_reports(status)`, `mark_report(name, status)` | see replies, close them |
66
+ | `/browser/mcp` | Playwright MCP on this computer behind the same token |
67
+
68
+ ## Auto-replies
69
+
70
+ When `BRIDGE_DESKTOP_AGENT` is `claude` or `codex` (auto-detected), every
71
+ report starts a **read-only** run of that agent in `BRIDGE_AGENT_CWD`
72
+ (`claude -p … --allowedTools Read,Grep,Glob,LS --max-turns 8` or
73
+ `codex exec --sandbox read-only …`). The answer lands as
74
+ `reports/<stamp>-reply-<slug>.md`, the report becomes `status: answered`,
75
+ and a desktop notification fires. The worker never edits files; when a
76
+ change is needed it says "needs a human coding session", and your next
77
+ session picks the report up from the mailbox.
78
+
79
+ The desktop agent must be logged in for the user running the bridge:
80
+ run `claude` (then `/login`) or `codex login` once in a terminal.
81
+ `elvarone-bridge doctor` checks this.
82
+
83
+ ### Telling the phone
84
+
85
+ Set `AGORADM_TOKEN` (an AgoraDM bot token for this computer — pair one at
86
+ agoradigest.com/bring-agent) and `BRIDGE_NOTIFY_BOT` (the phone agent's
87
+ bot id) and install the extra: `pip install 'elvarone-bridge[agoradm]'`.
88
+ The bridge then DMs the phone agent when a reply is ready; the DM wakes
89
+ ElvarOne, so the phone reads the answer without polling.
90
+
91
+ ## Security
92
+
93
+ - The token is required on every request. `init` mints a 32-byte random one.
94
+ - Binds `0.0.0.0` (LAN + Tailscale). **Never port-forward it to the internet.**
95
+ Install Tailscale on both devices to use the bridge away from home.
96
+ - Every remote call is approved on the phone. Pre-approve only read-only
97
+ tools (`screenshot`, `list_reports`, `read_file`, `report_to_desktop_agent`).
98
+ - Commands that would stop the bridge's own services are refused.
99
+ - Published files are signed links valid `BRIDGE_FILE_URL_TTL_S` (2 h) for one file.
100
+
101
+ ## Configuration
102
+
103
+ `~/ElvarBridge/config.env` (mode 600), or the same keys in the environment:
104
+
105
+ ```
106
+ BRIDGE_TOKEN=… required
107
+ BRIDGE_PORT=8787
108
+ BRIDGE_BIND=0.0.0.0
109
+ BRIDGE_PUBLIC_URL= override the URL handed to the phone
110
+ BRIDGE_FILE_URL_TTL_S=7200
111
+ BRIDGE_DESKTOP_AGENT= claude | codex | none (auto)
112
+ BRIDGE_AGENT_CWD= project the desktop agent reads (default ~/ElvarBridge)
113
+ BRIDGE_AGENT_MAX_TURNS=8
114
+ BRIDGE_AGENT_TIMEOUT_S=600
115
+ BRIDGE_CLAUDE_ARGS= extra flags for claude -p (e.g. --max-budget-usd 0.50)
116
+ BRIDGE_NOTIFY_DESKTOP=1
117
+ AGORADM_TOKEN= optional, see "Telling the phone"
118
+ BRIDGE_NOTIFY_BOT=
119
+ ```
120
+
121
+ Upgrading from the original `mac_bridge.py` launchd setup: `pip install -e .`
122
+ in the same venv keeps the old plist working; `elvarone-bridge init` migrates
123
+ to the packaged service.
@@ -0,0 +1,95 @@
1
+ # elvarone-bridge
2
+
3
+ The ElvarOne phone agent's compute bridge: a small MCP server on your
4
+ computer that lets the agent run Python and shell commands, take
5
+ screenshots it can actually look at, publish files, and leave written
6
+ reports that the coding agent on your computer (Claude Code or Codex)
7
+ answers automatically.
8
+
9
+ ```
10
+ pip install elvarone-bridge
11
+ elvarone-bridge init
12
+ ```
13
+
14
+ `init` mints a token, writes `~/ElvarBridge/config.env`, installs a
15
+ background service (launchd on macOS, a systemd user unit on Linux, a
16
+ Task Scheduler command on Windows) and prints the URL + token to enter
17
+ on the phone: **Settings → AI Brain → MCP Servers → Add**.
18
+
19
+ ## Let your desktop AI set it up
20
+
21
+ Paste this to Claude Code, Codex, Cursor or any coding agent on the
22
+ computer you want to connect:
23
+
24
+ > Install the ElvarOne compute bridge on this computer: `pip install elvarone-bridge` (use a venv or pipx if you prefer), then run `elvarone-bridge init --agent claude --project <the project you want me to answer questions about>`. Show me the "Phone settings" block it prints (URL and token) so I can enter them in the ElvarOne app. Then run `elvarone-bridge doctor` and fix anything it flags. Add this line to your project instructions (CLAUDE.md or AGENTS.md): "At the start of each session, list ~/ElvarBridge/reports/*.md and answer any file with status: unread; write replies as <stamp>-reply-<slug>.md in the same folder and set the report's status to read."
25
+
26
+ Replace `--agent claude` with `--agent codex` for Codex, or `--agent none`
27
+ to keep reports for a human session only.
28
+
29
+ ## What the phone agent gets
30
+
31
+ | tool | what it does |
32
+ |---|---|
33
+ | `run_shell`, `run_python`, `read_file` | compute and files inside `~/ElvarBridge` (the shell is human-approved on the phone for every call) |
34
+ | `screenshot(app, max_width, delay_s)` | capture the screen, downscale, return a **signed, expiring URL** the phone's `view_image` reads — no token in the link |
35
+ | `publish_file(path)` | same signed link for any file in the workdir |
36
+ | `report_to_desktop_agent(title, body, kind, from_agent)` | file a bug / request / finding / question in `~/ElvarBridge/reports/` |
37
+ | `list_reports(status)`, `mark_report(name, status)` | see replies, close them |
38
+ | `/browser/mcp` | Playwright MCP on this computer behind the same token |
39
+
40
+ ## Auto-replies
41
+
42
+ When `BRIDGE_DESKTOP_AGENT` is `claude` or `codex` (auto-detected), every
43
+ report starts a **read-only** run of that agent in `BRIDGE_AGENT_CWD`
44
+ (`claude -p … --allowedTools Read,Grep,Glob,LS --max-turns 8` or
45
+ `codex exec --sandbox read-only …`). The answer lands as
46
+ `reports/<stamp>-reply-<slug>.md`, the report becomes `status: answered`,
47
+ and a desktop notification fires. The worker never edits files; when a
48
+ change is needed it says "needs a human coding session", and your next
49
+ session picks the report up from the mailbox.
50
+
51
+ The desktop agent must be logged in for the user running the bridge:
52
+ run `claude` (then `/login`) or `codex login` once in a terminal.
53
+ `elvarone-bridge doctor` checks this.
54
+
55
+ ### Telling the phone
56
+
57
+ Set `AGORADM_TOKEN` (an AgoraDM bot token for this computer — pair one at
58
+ agoradigest.com/bring-agent) and `BRIDGE_NOTIFY_BOT` (the phone agent's
59
+ bot id) and install the extra: `pip install 'elvarone-bridge[agoradm]'`.
60
+ The bridge then DMs the phone agent when a reply is ready; the DM wakes
61
+ ElvarOne, so the phone reads the answer without polling.
62
+
63
+ ## Security
64
+
65
+ - The token is required on every request. `init` mints a 32-byte random one.
66
+ - Binds `0.0.0.0` (LAN + Tailscale). **Never port-forward it to the internet.**
67
+ Install Tailscale on both devices to use the bridge away from home.
68
+ - Every remote call is approved on the phone. Pre-approve only read-only
69
+ tools (`screenshot`, `list_reports`, `read_file`, `report_to_desktop_agent`).
70
+ - Commands that would stop the bridge's own services are refused.
71
+ - Published files are signed links valid `BRIDGE_FILE_URL_TTL_S` (2 h) for one file.
72
+
73
+ ## Configuration
74
+
75
+ `~/ElvarBridge/config.env` (mode 600), or the same keys in the environment:
76
+
77
+ ```
78
+ BRIDGE_TOKEN=… required
79
+ BRIDGE_PORT=8787
80
+ BRIDGE_BIND=0.0.0.0
81
+ BRIDGE_PUBLIC_URL= override the URL handed to the phone
82
+ BRIDGE_FILE_URL_TTL_S=7200
83
+ BRIDGE_DESKTOP_AGENT= claude | codex | none (auto)
84
+ BRIDGE_AGENT_CWD= project the desktop agent reads (default ~/ElvarBridge)
85
+ BRIDGE_AGENT_MAX_TURNS=8
86
+ BRIDGE_AGENT_TIMEOUT_S=600
87
+ BRIDGE_CLAUDE_ARGS= extra flags for claude -p (e.g. --max-budget-usd 0.50)
88
+ BRIDGE_NOTIFY_DESKTOP=1
89
+ AGORADM_TOKEN= optional, see "Telling the phone"
90
+ BRIDGE_NOTIFY_BOT=
91
+ ```
92
+
93
+ Upgrading from the original `mac_bridge.py` launchd setup: `pip install -e .`
94
+ in the same venv keeps the old plist working; `elvarone-bridge init` migrates
95
+ to the packaged service.
@@ -0,0 +1,12 @@
1
+ """elvarone-bridge — the phone agent's compute bridge on your computer.
2
+
3
+ elvarone-bridge init # mint a token, install the service, print phone settings
4
+ elvarone-bridge run # run in the foreground
5
+ elvarone-bridge doctor # check token, reachability, desktop agent login
6
+
7
+ See README.md for the paste-to-your-desktop-AI setup prompt.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,135 @@
1
+ """Auto-reply: when the phone agent files a report, ask the desktop coding
2
+ agent on this computer to read it and answer — then tell everyone.
3
+
4
+ report written → worker thread → `claude -p` (or `codex exec`), READ-ONLY
5
+ → reply file in ~/ElvarBridge/reports/
6
+ → report status: answered
7
+ → OS notification + (optional) AgoraDM DM to the phone agent
8
+
9
+ The worker never edits files: it runs with read-only tools and a turn
10
+ cap, so a bad report cannot turn into a bad commit. Anything that needs
11
+ code changes ends with "needs a human coding session", and the human's
12
+ session picks the report up from the mailbox as before.
13
+
14
+ The phone agent is told "your reply is ready" over AgoraDM when
15
+ AGORADM_TOKEN and BRIDGE_NOTIFY_BOT are configured — that push wakes
16
+ ElvarOne, so the phone reads the answer without polling.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import shlex
23
+ import subprocess
24
+ import threading
25
+ from typing import Optional
26
+
27
+ from . import config
28
+ from .mailbox import Report, set_status, write_reply
29
+
30
+ _LOG = logging.getLogger("elvarone-bridge.autoreply")
31
+
32
+ PROMPT = """You are the desktop coding agent on this computer. A phone agent (ElvarOne) filed the report below in ~/ElvarBridge/reports/{name}.
33
+
34
+ Read it, verify what you can in the project at {cwd} (read-only: look at code and logs, run nothing that changes anything), and write a concise reply:
35
+ 1. what in the report is confirmed, what is not (with file/line pointers when you looked);
36
+ 2. the likely cause;
37
+ 3. what to do next — and say plainly "needs a human coding session" when a change is required;
38
+ 4. anything the phone agent should do differently.
39
+
40
+ Reply in the language the report is written in. Markdown, under 400 words. Do not modify files.
41
+
42
+ --- REPORT ({kind}, from {sender}) ---
43
+ {body}
44
+ --- END ---"""
45
+
46
+
47
+ def _claude_cmd(prompt: str) -> list[str]:
48
+ turns = str(config.env_int("BRIDGE_AGENT_MAX_TURNS", 8))
49
+ extra = shlex.split(config.env("BRIDGE_CLAUDE_ARGS"))
50
+ return ["claude", "-p", prompt, "--output-format", "text", "--max-turns", turns,
51
+ "--allowedTools", "Read,Grep,Glob,LS", *extra]
52
+
53
+
54
+ def _codex_cmd(prompt: str) -> list[str]:
55
+ extra = shlex.split(config.env("BRIDGE_CODEX_ARGS"))
56
+ return ["codex", "exec", "--sandbox", "read-only", *extra, prompt]
57
+
58
+
59
+ def run_agent(report: Report) -> tuple[str, str]:
60
+ """Returns (agent_name, reply_text). Raises on failure."""
61
+ agent = config.desktop_agent()
62
+ if agent == "none":
63
+ raise RuntimeError("no desktop agent configured (BRIDGE_DESKTOP_AGENT=claude|codex)")
64
+ prompt = PROMPT.format(name=report.name, cwd=config.agent_cwd(), kind=report.kind,
65
+ sender=report.sender or "phone agent", body=report.body[:12000])
66
+ cmd = _claude_cmd(prompt) if agent == "claude" else _codex_cmd(prompt)
67
+ timeout = config.env_int("BRIDGE_AGENT_TIMEOUT_S", 600)
68
+ config.agent_cwd().mkdir(parents=True, exist_ok=True)
69
+ proc = subprocess.run(cmd, cwd=str(config.agent_cwd()), capture_output=True, text=True, timeout=timeout,
70
+ stdin=subprocess.DEVNULL)
71
+ out = (proc.stdout or "").strip()
72
+ err = (proc.stderr or "").strip()
73
+ if proc.returncode != 0 or not out:
74
+ raise RuntimeError(f"{agent} exited {proc.returncode}: {(err or out)[:600]}")
75
+ return agent, out
76
+
77
+
78
+ def notify_desktop(title: str, text: str) -> None:
79
+ if config.env("BRIDGE_NOTIFY_DESKTOP", "1") != "1":
80
+ return
81
+ try:
82
+ if config.is_macos():
83
+ safe_t = title.replace('"', "'")[:80]
84
+ safe_x = text.replace('"', "'")[:200]
85
+ subprocess.run(["osascript", "-e", f'display notification "{safe_x}" with title "{safe_t}"'],
86
+ capture_output=True, timeout=10)
87
+ elif not config.is_windows():
88
+ subprocess.run(["notify-send", title[:80], text[:200]], capture_output=True, timeout=10)
89
+ except (OSError, subprocess.TimeoutExpired):
90
+ pass
91
+
92
+
93
+ def notify_phone(report: Report, reply: Report) -> Optional[str]:
94
+ """DM the phone agent over AgoraDM that its report has an answer.
95
+ Returns a short status string for logs, or None when not configured."""
96
+ tok = config.env("AGORADM_TOKEN")
97
+ bot = config.env("BRIDGE_NOTIFY_BOT")
98
+ if not tok or not bot:
99
+ return None
100
+ try:
101
+ from agoradm import AgentClient # optional dependency
102
+ except ImportError:
103
+ return "agoradm not installed (pip install 'elvarone-bridge[agoradm]')"
104
+ excerpt = reply.body.strip()
105
+ if len(excerpt) > 1500:
106
+ excerpt = excerpt[:1500].rstrip() + " …"
107
+ text = (
108
+ f"Your report “{report.title}” has a reply from the desktop agent on this computer.\n"
109
+ f"Read the full file with read_file path=reports/{reply.name} on the bridge.\n\n{excerpt}"
110
+ )
111
+ try:
112
+ client = AgentClient(token=tok)
113
+ r = client.dm.send(bot, text)
114
+ return f"DM sent to {bot}: {getattr(r, 'task_id', r) if r is not None else 'ok'}"
115
+ except Exception as e: # noqa: BLE001
116
+ return f"DM failed: {e}"
117
+
118
+
119
+ def answer_in_background(report: Report) -> None:
120
+ def _work() -> None:
121
+ try:
122
+ agent, text = run_agent(report)
123
+ except Exception as e: # noqa: BLE001
124
+ _LOG.warning("auto-reply for %s failed: %s", report.name, e)
125
+ notify_desktop("ElvarOne report waiting", f"{report.title} — no auto-reply ({e})"[:200])
126
+ return
127
+ reply = write_reply(report, text, sender=f"{agent} (auto-reply)")
128
+ set_status(report, "answered")
129
+ _LOG.info("auto-replied to %s with %s", report.name, reply.name)
130
+ notify_desktop("ElvarOne report answered", report.title)
131
+ status = notify_phone(report, reply)
132
+ if status:
133
+ _LOG.info("phone notify: %s", status)
134
+
135
+ threading.Thread(target=_work, name=f"autoreply:{report.name}", daemon=True).start()
@@ -0,0 +1,231 @@
1
+ """elvarone-bridge command line.
2
+
3
+ elvarone-bridge init [--token T] [--port N] [--agent claude|codex|none] [--project DIR]
4
+ Mint a token (or take yours), write ~/ElvarBridge/config.env, install
5
+ a background service (macOS launchd / Linux systemd user unit; prints a
6
+ Task Scheduler command on Windows) and print the settings for the phone.
7
+ elvarone-bridge run Run in the foreground (what the service runs).
8
+ elvarone-bridge doctor Check config, port, reachability, desktop agent login.
9
+ elvarone-bridge settings Print the phone-side settings block again.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import secrets
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import __version__, config
23
+
24
+ SERVICE_MAC = "com.elvarone.bridge"
25
+ SERVICE_LINUX = "elvarone-bridge"
26
+
27
+
28
+ def _write_config(values: dict[str, str]) -> None:
29
+ config.ensure_dirs()
30
+ existing = config.load_config_file()
31
+ existing.update({k: v for k, v in values.items() if v})
32
+ lines = ["# elvarone-bridge configuration (mode 600). Edit and restart the service."]
33
+ for k, v in existing.items():
34
+ lines.append(f"{k}={v}")
35
+ config.CONFIG_FILE.write_text("\n".join(lines) + "\n")
36
+ try:
37
+ os.chmod(config.CONFIG_FILE, 0o600)
38
+ except OSError:
39
+ pass
40
+
41
+
42
+ def _python() -> str:
43
+ return sys.executable
44
+
45
+
46
+ def _install_service() -> str:
47
+ """Install/refresh the background service. Returns the restart command."""
48
+ if config.is_macos():
49
+ plist = Path.home() / "Library" / "LaunchAgents" / f"{SERVICE_MAC}.plist"
50
+ plist.parent.mkdir(parents=True, exist_ok=True)
51
+ log = config.LOGDIR / "bridge.log"
52
+ path_env = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
53
+ plist.write_text(f"""<?xml version="1.0" encoding="UTF-8"?>
54
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
55
+ <plist version="1.0">
56
+ <dict>
57
+ <key>Label</key><string>{SERVICE_MAC}</string>
58
+ <key>ProgramArguments</key>
59
+ <array>
60
+ <string>{_python()}</string>
61
+ <string>-m</string>
62
+ <string>elvarone_bridge</string>
63
+ <string>run</string>
64
+ </array>
65
+ <key>EnvironmentVariables</key>
66
+ <dict>
67
+ <key>PATH</key><string>{path_env}</string>
68
+ <key>HOME</key><string>{Path.home()}</string>
69
+ </dict>
70
+ <key>WorkingDirectory</key><string>{config.WORKDIR}</string>
71
+ <key>RunAtLoad</key><true/>
72
+ <key>KeepAlive</key><true/>
73
+ <key>StandardOutPath</key><string>{log}</string>
74
+ <key>StandardErrorPath</key><string>{log}</string>
75
+ </dict>
76
+ </plist>
77
+ """)
78
+ uid = os.getuid()
79
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist)], capture_output=True)
80
+ subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist)], capture_output=True)
81
+ return f"launchctl kickstart -k gui/{uid}/{SERVICE_MAC}"
82
+ if config.is_windows():
83
+ cmd = f'schtasks /Create /F /SC ONLOGON /TN ElvarOneBridge /TR "\\"{_python()}\\" -m elvarone_bridge run"'
84
+ return f"(install once) {cmd}\n(restart) schtasks /End /TN ElvarOneBridge & schtasks /Run /TN ElvarOneBridge"
85
+ unit = Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_LINUX}.service"
86
+ unit.parent.mkdir(parents=True, exist_ok=True)
87
+ unit.write_text(f"""[Unit]
88
+ Description=ElvarOne compute bridge
89
+ After=network.target
90
+
91
+ [Service]
92
+ ExecStart={_python()} -m elvarone_bridge run
93
+ WorkingDirectory={config.WORKDIR}
94
+ Restart=always
95
+ RestartSec=3
96
+
97
+ [Install]
98
+ WantedBy=default.target
99
+ """)
100
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
101
+ subprocess.run(["systemctl", "--user", "enable", "--now", SERVICE_LINUX], capture_output=True)
102
+ return f"systemctl --user restart {SERVICE_LINUX}"
103
+
104
+
105
+ def _addresses() -> tuple[str, str]:
106
+ from .server_addr import lan_ip, tailscale_ip # light helpers, no server import
107
+
108
+ return tailscale_ip(), lan_ip()
109
+
110
+
111
+ def settings_block() -> str:
112
+ config.load_config_file()
113
+ ts, lan = _addresses()
114
+ port = config.port()
115
+ tok = config.token()
116
+ lines = [
117
+ "── Phone settings (ElvarOne → Settings → AI Brain → MCP Servers → Add) ──",
118
+ " name: mac (or pc / laptop — whatever you call this computer)",
119
+ ]
120
+ if ts:
121
+ lines.append(f" URL: http://{ts}:{port}/mcp (Tailscale — works away from home)")
122
+ lines.append(f" http://{lan}:{port}/mcp (LAN only)")
123
+ else:
124
+ lines.append(f" URL: http://{lan}:{port}/mcp (LAN; install Tailscale on both devices to use it away from home)")
125
+ lines.append(f" token: {tok}")
126
+ lines.append(" Pre-approve (optional, read-only): screenshot, list_reports, read_file, report_to_desktop_agent")
127
+ lines.append(f" Desktop agent for auto-replies: {config.desktop_agent()}")
128
+ return "\n".join(lines)
129
+
130
+
131
+ def cmd_init(args: argparse.Namespace) -> int:
132
+ tok = args.token or config.env("BRIDGE_TOKEN") or secrets.token_urlsafe(32)
133
+ values = {"BRIDGE_TOKEN": tok, "BRIDGE_PORT": str(args.port or config.port())}
134
+ if args.agent:
135
+ values["BRIDGE_DESKTOP_AGENT"] = args.agent
136
+ if args.project:
137
+ values["BRIDGE_AGENT_CWD"] = str(Path(args.project).expanduser().resolve())
138
+ if args.agoradm_token:
139
+ values["AGORADM_TOKEN"] = args.agoradm_token
140
+ if args.notify_bot:
141
+ values["BRIDGE_NOTIFY_BOT"] = args.notify_bot
142
+ _write_config(values)
143
+ os.environ["BRIDGE_TOKEN"] = tok
144
+ restart = _install_service() if not args.no_service else "(service not installed; run `elvarone-bridge run`)"
145
+ print(f"elvarone-bridge {__version__} configured at {config.CONFIG_FILE}")
146
+ print(f"restart command: {restart}")
147
+ print()
148
+ print(settings_block())
149
+ print()
150
+ print("Next: on the phone add the server with the URL + token above, then ask the phone agent to run `screenshot`.")
151
+ return 0
152
+
153
+
154
+ def cmd_run(_args: argparse.Namespace) -> int:
155
+ from .server import main
156
+
157
+ main()
158
+ return 0
159
+
160
+
161
+ def cmd_doctor(_args: argparse.Namespace) -> int:
162
+ config.load_config_file()
163
+ config.ensure_dirs()
164
+ ok = True
165
+
166
+ def line(good: bool, text: str) -> None:
167
+ nonlocal ok
168
+ ok = ok and good
169
+ print((" ✓ " if good else " ✗ ") + text)
170
+
171
+ print(f"elvarone-bridge {__version__} · python {sys.version.split()[0]} · {sys.platform}")
172
+ line(bool(config.token()), f"token configured ({config.CONFIG_FILE})" if config.token() else "no BRIDGE_TOKEN — run `elvarone-bridge init`")
173
+ ts, lan = _addresses()
174
+ line(True, f"addresses: tailscale={ts or 'none'} lan={lan} port={config.port()}")
175
+ try:
176
+ import httpx
177
+
178
+ r = httpx.get(f"http://127.0.0.1:{config.port()}/healthz", timeout=3)
179
+ line(r.status_code == 200, f"service answering on port {config.port()}: {r.text[:120]}")
180
+ except Exception as e: # noqa: BLE001
181
+ line(False, f"service not answering on port {config.port()} ({e.__class__.__name__}); start it with `elvarone-bridge run` or the service")
182
+ agent = config.desktop_agent()
183
+ if agent == "none":
184
+ line(True, "desktop agent: none (reports wait for a human session)")
185
+ else:
186
+ exe = shutil.which(agent)
187
+ line(bool(exe), f"desktop agent: {agent} at {exe or 'not on PATH'}")
188
+ if exe:
189
+ try:
190
+ cmd = [agent, "-p", "Reply with exactly: BRIDGE_OK", "--max-turns", "1"] if agent == "claude" else [agent, "exec", "--sandbox", "read-only", "Reply with exactly: BRIDGE_OK"]
191
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=90, cwd=str(config.agent_cwd()), stdin=subprocess.DEVNULL)
192
+ out = (proc.stdout + proc.stderr).strip()
193
+ line("BRIDGE_OK" in out, f"{agent} answers non-interactively" if "BRIDGE_OK" in out else f"{agent} could not answer: {out[:160]} — log in once (`{agent}` then /login) in a terminal as this user")
194
+ except Exception as e: # noqa: BLE001
195
+ line(False, f"{agent} check failed: {e}")
196
+ if config.env("AGORADM_TOKEN") and config.env("BRIDGE_NOTIFY_BOT"):
197
+ line(True, f"phone notifications over AgoraDM → {config.env('BRIDGE_NOTIFY_BOT')}")
198
+ else:
199
+ line(True, "phone notifications: off (set AGORADM_TOKEN + BRIDGE_NOTIFY_BOT to DM the phone agent when a reply is ready)")
200
+ if config.is_macos():
201
+ print(" · Screen Recording: if `screenshot` returns only wallpaper, enable it for the app that launches the bridge in System Settings → Privacy & Security.")
202
+ return 0 if ok else 1
203
+
204
+
205
+ def cmd_settings(_args: argparse.Namespace) -> int:
206
+ print(settings_block())
207
+ return 0
208
+
209
+
210
+ def main(argv: list[str] | None = None) -> int:
211
+ p = argparse.ArgumentParser(prog="elvarone-bridge", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
212
+ p.add_argument("--version", action="version", version=f"elvarone-bridge {__version__}")
213
+ sub = p.add_subparsers(dest="cmd", required=True)
214
+ i = sub.add_parser("init", help="configure, install the service, print phone settings")
215
+ i.add_argument("--token", help="use this bearer token instead of minting one")
216
+ i.add_argument("--port", type=int)
217
+ i.add_argument("--agent", choices=["claude", "codex", "none"], help="desktop agent for auto-replies")
218
+ i.add_argument("--project", help="project directory the desktop agent reads when answering reports")
219
+ i.add_argument("--agoradm-token", help="AgoraDM bot token so replies are pushed to the phone agent")
220
+ i.add_argument("--notify-bot", help="the phone agent's bot id to DM when a reply is ready")
221
+ i.add_argument("--no-service", action="store_true", help="only write config; do not install a background service")
222
+ i.set_defaults(fn=cmd_init)
223
+ sub.add_parser("run", help="run in the foreground").set_defaults(fn=cmd_run)
224
+ sub.add_parser("doctor", help="check the installation").set_defaults(fn=cmd_doctor)
225
+ sub.add_parser("settings", help="print the phone-side settings").set_defaults(fn=cmd_settings)
226
+ args = p.parse_args(argv)
227
+ return int(args.fn(args))
228
+
229
+
230
+ if __name__ == "__main__":
231
+ raise SystemExit(main())