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.
- elvarone_bridge-0.1.0/PKG-INFO +123 -0
- elvarone_bridge-0.1.0/README.md +95 -0
- elvarone_bridge-0.1.0/elvarone_bridge/__init__.py +12 -0
- elvarone_bridge-0.1.0/elvarone_bridge/__main__.py +3 -0
- elvarone_bridge-0.1.0/elvarone_bridge/autoreply.py +135 -0
- elvarone_bridge-0.1.0/elvarone_bridge/cli.py +231 -0
- elvarone_bridge-0.1.0/elvarone_bridge/config.py +105 -0
- elvarone_bridge-0.1.0/elvarone_bridge/mailbox.py +120 -0
- elvarone_bridge-0.1.0/elvarone_bridge/screenshot.py +80 -0
- elvarone_bridge-0.1.0/elvarone_bridge/server.py +401 -0
- elvarone_bridge-0.1.0/elvarone_bridge/server_addr.py +31 -0
- elvarone_bridge-0.1.0/elvarone_bridge.egg-info/PKG-INFO +123 -0
- elvarone_bridge-0.1.0/elvarone_bridge.egg-info/SOURCES.txt +18 -0
- elvarone_bridge-0.1.0/elvarone_bridge.egg-info/dependency_links.txt +1 -0
- elvarone_bridge-0.1.0/elvarone_bridge.egg-info/entry_points.txt +2 -0
- elvarone_bridge-0.1.0/elvarone_bridge.egg-info/requires.txt +10 -0
- elvarone_bridge-0.1.0/elvarone_bridge.egg-info/top_level.txt +1 -0
- elvarone_bridge-0.1.0/pyproject.toml +44 -0
- elvarone_bridge-0.1.0/setup.cfg +4 -0
- elvarone_bridge-0.1.0/tests/test_bridge.py +82 -0
|
@@ -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,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())
|