agent: headless desktop clipboard agent on tethercore (macOS/Linux/Windows)

A single Rust binary: the shared tethercore engine (SSE + RTC) wired to the OS
clipboard via arboard. Inbound room clipboard → local clipboard; local changes
→ published to the room, with echo suppression. No UI — meant to run as a
login/service daemon. One codebase covers all three desktops.

  tether-agent --server http://host:8765 --room <id> [--source label]

Verified on macOS against the live server: RECEIVE (bus → clipboard via pbpaste)
and SEND (pbcopy → bus) both confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 23:38:24 -05:00
parent db64b3b9a6
commit 1caa44e253
4 changed files with 3894 additions and 0 deletions

1
agent/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target/

3794
agent/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
agent/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "tether-agent"
version = "0.1.0"
edition = "2021"
description = "Headless tether clipboard agent: shared tethercore engine + native clipboard (arboard). Runs on macOS, Linux, Windows."
[[bin]]
name = "tether-agent"
path = "src/main.rs"
[dependencies]
tethercore = { path = "../core" }
arboard = "3"

86
agent/src/main.rs Normal file
View File

@@ -0,0 +1,86 @@
//! tether-agent — headless clipboard sync for desktops.
//!
//! The same shared `tethercore` engine (SSE + RTC) that backs the native apps,
//! wired to the OS clipboard via `arboard`. One Rust binary covers macOS, Linux,
//! and Windows. No UI; meant to run as a login/service daemon.
//!
//! tether-agent --server http://host:8765 --room <id> [--source label]
//!
//! Inbound clipboard from the room is written to the local clipboard; local
//! clipboard changes are published to the room. Echo is suppressed by tracking
//! the last value we set or sent.
use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::Duration;
use arboard::Clipboard;
use tethercore::{Engine, Message, MessageHandler};
/// Inbound sink: the engine calls this on its runtime thread; we just queue the
/// text for the main loop, which owns the (non-Send) clipboard handle.
struct Inbox(Arc<Mutex<Vec<String>>>);
impl MessageHandler for Inbox {
fn on_message(&self, msg: Message) {
eprintln!("← [{}] {}", msg.source, preview(&msg.text));
self.0.lock().unwrap().push(msg.text);
}
fn on_status(&self, connected: bool) {
eprintln!("{}", if connected { "connected" } else { "disconnected" });
}
}
fn preview(s: &str) -> String {
let t = s.replace('\n', " ");
if t.chars().count() > 60 {
format!("{}", t.chars().take(60).collect::<String>())
} else {
t
}
}
fn arg(flag: &str, default: &str) -> String {
let a: Vec<String> = std::env::args().collect();
a.iter()
.position(|x| x == flag)
.and_then(|i| a.get(i + 1))
.cloned()
.unwrap_or_else(|| default.to_string())
}
fn main() {
let server = arg("--server", "http://localhost:8765");
let room = arg("--room", "default");
let source = arg("--source", "agent");
let from = arg("--from", &format!("agent-{source}"));
eprintln!("tether-agent → {server} room={room} source={source}");
let inbox = Arc::new(Mutex::new(Vec::<String>::new()));
let engine = Engine::new(server, room, from, source);
engine.start(Box::new(Inbox(inbox.clone())));
let mut clip = Clipboard::new().expect("open system clipboard");
let mut last = clip.get_text().unwrap_or_default();
loop {
// Apply inbound: received text → OS clipboard.
let pending: Vec<String> = std::mem::take(&mut *inbox.lock().unwrap());
for text in pending {
if text != last {
let _ = clip.set_text(text.clone());
last = text;
}
}
// Detect a local change → publish to the room.
if let Ok(cur) = clip.get_text() {
if !cur.is_empty() && cur != last {
last = cur.clone();
eprintln!("{}", preview(&cur));
engine.send(cur);
}
}
sleep(Duration::from_millis(500));
}
}