commit 6aaf913366b813c9c9faf17a9a860dfebc49df12 Author: OpenCode Agent <> Date: Mon Aug 17 15:15:05 2026 +0000 feat: hermes sshx-link plugin - shared terminal from dashboard diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e89fbf6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,106 @@ +""" +sshx-link plugin +================ +Downloads sshx, starts a shared terminal session, +and returns the shareable link. +""" + +import subprocess +import threading +import re +import logging +import shutil +import os + +logger = logging.getLogger(__name__) + +_sshx_process = None +_sshx_link = None + + +def _find_link_from_output(line: str) -> str | None: + """Extract sshx link from process output.""" + # sshx outputs links like: https://sshx.io/s/xxxxx + match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", line) + return match.group(0) if match else None + + +def _ensure_sshx_installed() -> bool: + """Download and install sshx binary if not present.""" + if shutil.which("sshx"): + logger.info("sshx already installed at %s", shutil.which("sshx")) + return True + + logger.info("Downloading sshx...") + try: + result = subprocess.run( + ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + logger.error("sshx install failed: %s", result.stderr) + return False + return True + except subprocess.TimeoutExpired: + logger.error("sshx install timed out") + return False + + +def _run_sshx(tool_ctx): + """Run sshx serve in background, capture the link.""" + global _sshx_process, _sshx_link + + if _sshx_process and _sshx_process.poll() is None: + # Already running + tool_ctx.yield_result({"status": "already_running", "link": _sshx_link}) + return + + if not _ensure_sshx_installed(): + tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx"}) + return + + logger.info("Starting sshx serve...") + _sshx_process = subprocess.Popen( + ["sshx", "serve"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + _sshx_link = None + + def _reader(): + global _sshx_link + for line in _sshx_process.stdout: + line = line.strip() + logger.debug("sshx: %s", line) + link = _find_link_from_output(line) + if link: + _sshx_link = link + tool_ctx.yield_result({"status": "running", "link": link}) + return + # Process ended without outputting a link + tool_ctx.yield_result({"status": "error", "message": "sshx exited without producing a link"}) + + threading.Thread(target=_reader, daemon=True).start() + + +def register(ctx): + """Register the sshx_start tool.""" + ctx.register_tool( + name="sshx_start", + toolset="sshx-link", + schema={ + "description": "Start a sshx shared terminal session and return the shareable link. The link lets others join your terminal via browser.", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + handler=_run_sshx, + ) + logger.info("sshx-link plugin registered") diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js new file mode 100644 index 0000000..5daf332 --- /dev/null +++ b/dashboard/dist/index.js @@ -0,0 +1,132 @@ +(function() { + const SDK = window.__HERMES_PLUGIN_SDK__; + const { useState, useEffect, useCallback } = React; + + function SSHXPage() { + const [link, setLink] = useState(null); + const [status, setStatus] = useState("idle"); // idle | loading | running | error + const [error, setError] = useState(null); + + const startSSHX = useCallback(async () => { + setStatus("loading"); + setError(null); + try { + const res = await SDK.fetchJSON("/api/plugins/sshx-link/start"); + if (res.link) { + setLink(res.link); + setStatus("running"); + } else { + setError(res.message || "No link returned"); + setStatus("error"); + } + } catch (err) { + setError(err.message || "Failed to start sshx"); + setStatus("error"); + } + }, []); + + const stopSSHX = useCallback(async () => { + try { + await fetch("/api/plugins/sshx-link/stop", { method: "POST" }); + setLink(null); + setStatus("idle"); + } catch (err) { + setError(err.message); + } + }, []); + + const checkStatus = useCallback(async () => { + try { + const res = await SDK.fetchJSON("/api/plugins/sshx-link/status"); + if (res.status === "running" && res.link) { + setLink(res.link); + setStatus("running"); + } + } catch (_) {} + }, []); + + useEffect(() => { checkStatus(); }, [checkStatus]); + + return ( +
+

+ SSHX Shared Terminal +

+

+ Start a shared terminal session. Others can join via the link. +

+ + {status === "running" && link ? ( +
+

+ Running +

+ + {link} + +
+ +
+
+ ) : ( + + )} + + {error && ( +

+ Error: {error} +

+ )} +
+ ); + } + + // Register the tab component + if (window.__HERMES_PLUGINS__) { + window.__HERMES_PLUGINS__.register("sshx-link", SSHXPage); + } +})(); diff --git a/dashboard/manifest.json b/dashboard/manifest.json new file mode 100644 index 0000000..dc18972 --- /dev/null +++ b/dashboard/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "sshx-link", + "label": "SSHX", + "description": "Start a shared terminal session and get the link", + "icon": "Terminal", + "version": "1.0.0", + "tab": { + "path": "/sshx", + "position": "end" + }, + "entry": "dist/index.js", + "api": "plugin_api.py" +} diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py new file mode 100644 index 0000000..d566366 --- /dev/null +++ b/dashboard/plugin_api.py @@ -0,0 +1,136 @@ +""" +sshx-link dashboard backend +=========================== +FastAPI routes for starting sshx and returning the share link. +""" + +import asyncio +import subprocess +import re +import shutil +import logging +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +logger = logging.getLogger(__name__) +router = APIRouter() + +# Keep track of running sshx process +_sshx_process = None +_sshx_link = None + + +def _find_link(text: str) -> str | None: + """Extract sshx link from output.""" + match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", text) + return match.group(0) if match else None + + +def _ensure_sshx() -> bool: + """Install sshx if not present.""" + if shutil.which("sshx"): + return True + try: + subprocess.run( + ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], + capture_output=True, + timeout=60, + ) + return shutil.which("sshx") is not None + except Exception as e: + logger.error("sshx install failed: %s", e) + return False + + +@router.get("/start") +async def start_sshx(): + """Start sshx serve and return the share link.""" + global _sshx_process, _sshx_link + + # Already running + if _sshx_process and _sshx_process.poll() is None and _sshx_link: + return {"status": "running", "link": _sshx_link} + + # Install if needed + if not _ensure_sshx(): + return JSONResponse( + status_code=500, + content={"status": "error", "message": "Failed to install sshx"}, + ) + + # Kill old process if any + if _sshx_process and _sshx_process.poll() is None: + _sshx_process.terminate() + _sshx_process.wait(timeout=5) + + # Start sshx serve + _sshx_process = subprocess.Popen( + ["sshx", "serve"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + _sshx_link = None + + # Read output in background thread, await link + loop = asyncio.get_event_loop() + link_future = loop.create_future() + + def _reader(): + global _sshx_link + for line in _sshx_process.stdout: + line = line.strip() + link = _find_link(line) + if link: + _sshx_link = link + if not link_future.done(): + link_future.set_result(link) + return + if not link_future.done(): + link_future.set_exception RuntimeError("sshx exited without link") + + import threading + threading.Thread(target=_reader, daemon=True).start() + + try: + link = await asyncio.wait_for(link_future, timeout=30) + return {"status": "running", "link": link} + except asyncio.TimeoutError: + return JSONResponse( + status_code=504, + content={"status": "error", "message": "sshx did not return a link in time"}, + ) + except Exception as e: + return JSONResponse( + status_code=500, + content={"status": "error", "message": str(e)}, + ) + + +@router.get("/status") +async def sshx_status(): + """Check if sshx is running and return the link.""" + global _sshx_process, _sshx_link + + if _sshx_process and _sshx_process.poll() is None and _sshx_link: + return {"status": "running", "link": _sshx_link} + return {"status": "stopped", "link": None} + + +@router.post("/stop") +async def stop_sshx(): + """Stop the running sshx process.""" + global _sshx_process, _sshx_link + + if _sshx_process and _sshx_process.poll() is None: + _sshx_process.terminate() + try: + _sshx_process.wait(timeout=5) + except subprocess.TimeoutExpired: + _sshx_process.kill() + _sshx_process = None + _sshx_link = None + return {"status": "stopped"} + return {"status": "already_stopped"} diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..8531f8d --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,8 @@ +name: sshx-link +version: 1.0.0 +description: Start sshx shared terminal and return the share link +author: sisyphus +provides_tools: + - sshx_start +provides_hooks: [] +requires_env: []