have main issue

This commit is contained in:
daniel wang 2026-09-16 06:40:00 +00:00
parent 824e4026dd
commit 7e6084baeb
29 changed files with 1128 additions and 384 deletions

View File

@ -28,7 +28,7 @@ LAZYBOY_API_KEY=
# 網頁 UI / APImake start → lazyboy serve
# =============================================================================
# HTTP 綁定位址。0.0.0.0 才能用同一 Wi-Fi 的手機開
# HTTP 綁定位址。預設 0.0.0.0,同一 Wi-Fi 的手機用印出的 http://LAN:8787
# LAZYBOY_WEB_HOST=0.0.0.0
# JSON API + 若有 web/dist 時的 UI 埠。Vite 開發前端仍是 5173會把 /api 代理到這裡。

View File

@ -18,7 +18,7 @@ PID_WEB := $(RUNDIR)/web.pid
LOG_SERVE := $(RUNDIR)/serve.log
LOG_WEB := $(RUNDIR)/web.log
.PHONY: help start stop restart status logs start-serve start-web stop-serve stop-web
.PHONY: help start stop restart status logs prepare-ui start-serve start-web stop-serve stop-web
# Shared POSIX helpers. Sourced by each recipe.
define CTL
@ -159,10 +159,14 @@ help:
@echo "make status Show PIDs and ports"
@echo "make logs Tail both logs (Ctrl-C to leave)"
start: start-serve start-web
start: prepare-ui start-serve start-web
@echo
@echo "API http://127.0.0.1:$(PORT_API)"
@echo "UI http://127.0.0.1:$(PORT_WEB)"
@lan=$$(hostname -I 2>/dev/null | awk '{print $$1}'); \
if [ -n "$$lan" ]; then \
echo "Phone http://$$lan:$(PORT_API)"; \
fi
restart: stop
@$(MAKE) start
@ -182,12 +186,27 @@ logs:
@echo "=== web $(LOG_WEB) ==="
@tail -f "$(LOG_SERVE)" "$(LOG_WEB)"
prepare-ui:
@if [ ! -d "$(WEBDIR)/node_modules" ]; then \
echo "npm install…"; \
(cd "$(WEBDIR)" && npm install) || exit 1; \
fi
@if [ ! -f "$(WEBDIR)/dist/index.html" ]; then \
echo "building web UI (once, so :$(PORT_API) serves the app)…"; \
(cd "$(WEBDIR)" && npm run build) || exit 1; \
fi
start-serve:
@mkdir -p "$(RUNDIR)"
@eval "$$CTL"; \
if alive "$(PID_SERVE)"; then \
echo "serve already running pid=$$(cat "$(PID_SERVE)")"; \
exit 0; \
fmt=$$(cat "$$HOME/.lazyboy/certs/format" 2>/dev/null || true); \
if [ "$$fmt" = "2" ]; then \
echo "serve already running pid=$$(cat "$(PID_SERVE)")"; \
exit 0; \
fi; \
echo "TLS certs need reissue; restarting serve"; \
stop_one serve "$(PID_SERVE)" "$(PORT_API)"; \
fi; \
echo "building lazyboy…"; \
cargo build -p lazyboy || exit 1; \
@ -201,14 +220,19 @@ start-web:
@mkdir -p "$(RUNDIR)"
@eval "$$CTL"; \
if alive "$(PID_WEB)"; then \
echo "web already running pid=$$(cat "$(PID_WEB)")"; \
exit 0; \
if lsof -nP -iTCP:"$(PORT_WEB)" -sTCP:LISTEN 2>/dev/null | grep -q '127.0.0.1:$(PORT_WEB)'; then \
echo "web is localhost-only; restarting so the phone can connect"; \
stop_one web "$(PID_WEB)" "$(PORT_WEB)"; \
else \
echo "web already running pid=$$(cat "$(PID_WEB)")"; \
exit 0; \
fi; \
fi; \
if [ ! -d "$(WEBDIR)/node_modules" ]; then \
echo "npm install…"; \
(cd "$(WEBDIR)" && npm install) || exit 1; \
fi; \
nohup npm --prefix "$(WEBDIR)" run dev -- --host 127.0.0.1 --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 & echo $$! > "$(PID_WEB)"; \
nohup npm --prefix "$(WEBDIR)" run dev -- --host 0.0.0.0 --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 & echo $$! > "$(PID_WEB)"; \
wait_alive "$(PID_WEB)" web "$(LOG_WEB)" || exit 1; \
wait_listen "$(PID_WEB)" "$(PORT_WEB)" web "$(LOG_WEB)" || exit 1; \
echo "started web pid=$$(cat "$(PID_WEB)") :$(PORT_WEB) log=$(LOG_WEB)"

View File

@ -48,9 +48,9 @@ See [team setup and behavior](docs/TEAM.md) for commands, budgets, privacy bound
| Explicit local files | `external_list_dir`, `external_read_file`, `external_edit_file`, `external_write_file`, `external_grep` (regex), `external_glob`, `external_search_files` (literal) |
| Web | `web_fetch` (direct anonymous HTTP GET, HTML → text, short cache), `web_search` (remote search service); no browser login; Docker browser for login-gated pages |
| Background | `spawn_subagent` (returns immediately; `kind=computerUse` for desktop GUI), `check_subagent`, `message_subagent`, `stop_subagent`. Subagents and external command completion revive the turn; observe detached box commands with `await_shell`. |
| Desktop GUI | Parent: `screenshot` (read-only). Clicks: `spawn_subagent kind=computerUse`, which gets `computer` (screenshot/click/move/drag/type/key/scroll/wait via xdotool on `DISPLAY=:1`). One computerUse at a time. Passwords still `request_box_help`. |
| Desktop GUI | Parent: `screenshot` (read-only). Clicks: `spawn_subagent kind=computerUse`, which gets `computer` (screenshot/click/move/drag/type/key/scroll/wait via xdotool on `DISPLAY=:1`). One computerUse per agent computer. Passwords still `request_box_help`. |
| MCP | `get_mcp_tools`, `call_mcp_tool`, `get_mcp_server_status`, `add_mcp_server`, `remove_mcp_server`. Config: `~/.lazyboy/mcp.json` (or `LAZYBOY_MCP_CONFIG`). Prefer a connector over the browser for that service. |
| My computer (box) | Docker Linux desktop (XFCE + **Chromium** + 終端機 + xdotool). Default tools: `shell`, `read`, `await_shell`, `screenshot`. Profile: `/home/box/chrome-profile`; the browser helper connects inside Docker to the same desktop browser. Viewer`lazyboy computer`。 |
| My computer (box) | Docker Linux desktop (XFCE + **Chromium** + 終端機 + xdotool). Named agents each get their own container; opening an agent starts it and the UI confirms ready/error. Session CLI keeps `lazyboy-box`. Default tools: `shell`, `read`, `await_shell`, `screenshot`. Profile: `/home/box/chrome-profile` on that seat. Viewer`lazyboy computer` or the agent overlay. |
| Browser observation | `browser_navigate`, `browser_snapshot`, `browser_read_page`, `browser_tabs` |
| Browser actions | `browser_click`, `browser_type`, `browser_press`, `browser_select`, `browser_scroll`, `browser_wait` |
| Browser file exchange | `browser_upload`, `browser_download` |

View File

@ -60,7 +60,7 @@ external_exec_command waits up to block_until_ms (default 30000ms) then backgrou
## Delegating background work
Use spawn_subagent for a self-contained chunk (research, files, a multi-step investigation). It returns immediately with subagent_id. After dispatch, do not sit idle: send_message that you kicked it off, keep working, or end the turn with no tool calls. You are revived automatically when it finishes never poll check_subagent for completion. check_subagent inspects a running child that may be stuck. message_subagent injects an instruction without aborting. stop_subagent aborts one. This revival is self-triggered, not someone reaching out; if the result is irrelevant and the user was not waiting, end with no send_message.
kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot, click, drag, type, key, scroll, and wait. Only one computerUse may run at a time (they share the screen); while it runs, leave the screen to it and use screenshot only to check in. Scope the goal tightly site, exact values, success criteria, stopping point. If it needs a human (password, 2FA, captcha, payment), it stops and reports; then you call request_box_help and dispatch it again to continue.
kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot, click, drag, type, key, scroll, and wait. Only one computerUse may run at a time on this agent's computer; while it runs, leave the screen to it and use screenshot only to check in. Scope the goal tightly site, exact values, success criteria, stopping point. If it needs a human (password, 2FA, captcha, payment), it stops and reports; then you call request_box_help and dispatch it again to continue.
## Two computers
You have two machines. To the user, call the box \"my computer\" and the launch machine \"your computer\". Never mix paths.

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@ struct Helper {
input: ChildStdin,
output: BufReader<ChildStdout>,
next: u64,
container: Option<String>,
}
impl Drop for Helper {
fn drop(&mut self) {
@ -90,16 +91,17 @@ impl BrowserClient {
.await;
}
}
pub async fn request(
pub async fn request_on(
&self,
cwd: &Path,
profile: Option<PathBuf>,
mut req: Value,
container: Option<&str>,
) -> Result<Value> {
let mut slot = self.state.lock().await;
let mut helper = match slot.take() {
Some(h) => h,
None => spawn(cwd, profile).await?,
Some(h) if container.is_none() || h.container.as_deref() == container => h,
Some(_) | None => spawn(cwd, profile, container).await?,
};
helper.next += 1;
let id = helper.next.to_string();
@ -138,17 +140,20 @@ impl BrowserClient {
Ok(reply)
}
}
async fn spawn(cwd: &Path, profile: Option<PathBuf>) -> Result<Helper> {
async fn spawn(cwd: &Path, profile: Option<PathBuf>, seat_id: Option<&str>) -> Result<Helper> {
if local_browser_enabled() {
let script = crate::browser::find_helper_script(cwd)
.ok_or_else(|| anyhow!("{}", crate::browser::INSTALL_HINT))?;
return spawn_script(cwd, &script, profile).await;
}
crate::box_runtime::BoxHub::new().ensure_browser_ready().await?;
let hub = crate::box_runtime::BoxPool::global().hub(seat_id.unwrap_or(crate::SESSION_SEAT));
hub.ensure_browser_ready().await?;
let mut command = tokio::process::Command::new("docker");
command.args(["exec", "-i", "-w", "/workspace", "-e", "LAZYBOY_BROWSER_CDP=http://127.0.0.1:9222",
"lazyboy-box", "flock", "-n", "/home/box/.lazyboy-browser.lock", "node", "/opt/lazyboy/playwright/browser_helper.mjs"]);
start_helper(command).await
hub.container(), "flock", "-n", "/home/box/.lazyboy-browser.lock", "node", "/opt/lazyboy/playwright/browser_helper.mjs"]);
let mut helper = start_helper(command).await?;
helper.container = Some(hub.seat_id().to_string());
Ok(helper)
}
/// Local browser is an explicit compatibility mode, never a Docker fallback.
@ -181,6 +186,7 @@ async fn start_helper(mut command: tokio::process::Command) -> Result<Helper> {
input,
output,
next: 0,
container: None,
})
}
@ -237,7 +243,7 @@ mod tests {
timeout: Some(Duration::from_millis(250)),
};
let error = client
.request(&dir, None, json!({"op":"ping"}))
.request_on(&dir, None, json!({"op":"ping"}), None)
.await
.unwrap_err();
assert!(error.to_string().contains("timed out"));
@ -249,7 +255,7 @@ mod tests {
.unwrap();
*client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap());
assert!(client
.request(&dir, None, json!({"op":"ping"}))
.request_on(&dir, None, json!({"op":"ping"}), None)
.await
.unwrap_err()
.to_string()
@ -259,7 +265,7 @@ mod tests {
*client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap());
assert!(tokio::time::timeout(
Duration::from_millis(20),
client.request(&dir, None, json!({"op":"ping"}))
client.request_on(&dir, None, json!({"op":"ping"}), None)
)
.await
.is_err());
@ -269,7 +275,7 @@ mod tests {
*client.state.lock().await = Some(spawn_script(&dir, &real_script, None).await.unwrap());
assert_eq!(
client
.request(&dir, None, json!({"op":"ping"}))
.request_on(&dir, None, json!({"op":"ping"}), None)
.await
.unwrap()["pong"],
true

View File

@ -29,7 +29,7 @@ pub use agent::{
truncate_messages, AgentVerdict, AGENT_SYSTEM, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS,
DEFAULT_MAX_ROUNDS_TOTAL, LOOP_GUARD_REPEAT, SEND_MESSAGE_SILENCE_THRESHOLD,
};
pub use box_runtime::BoxHub;
pub use box_runtime::{BoxHub, BoxPool, BoxSeat, SESSION_SEAT};
pub use browser::{
browser_oneshot, browser_self_test, find_helper_script, wait_for_handoff_resume, HandoffWait,
INSTALL_HINT as BROWSER_INSTALL_HINT,

View File

@ -1,16 +1,23 @@
//! Local HTTPS for the web UI. VNC stays on 127.0.0.1; browsers talk TLS to LazyBoy.
use anyhow::{Context, Result};
use chrono::{Datelike, Duration, Utc};
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
IsCa, KeyPair, KeyUsagePurpose, SanType,
date_time_ymd, BasicConstraints, CertificateParams, DistinguishedName, DnType,
ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::pki_types::PrivateKeyDer;
use rustls::ServerConfig;
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Bump when on-disk certs must be reissued. Format 1 used rcgen's 19754096
/// validity, which Firefox/NSS reports as SEC_ERROR_BAD_SIGNATURE (not bypassable).
const MATERIAL_FORMAT: &str = "2";
const CA_VALID_DAYS: i64 = 3650;
const SERVER_VALID_DAYS: i64 = 730;
pub struct TlsMaterial {
pub ca_pem: String,
pub cert_pem: String,
@ -37,15 +44,46 @@ pub fn cert_dir() -> PathBuf {
pub fn lan_ips() -> Vec<String> {
let mut ips = Vec::new();
if let Some(ip) = outbound_ipv4() {
push_ip(&mut ips, &ip);
}
for iface in ["en0", "en1", "eth0", "wlan0"] {
if let Ok(out) = std::process::Command::new("ipconfig")
.args(["getifaddr", iface])
.output()
{
if out.status.success() {
let ip = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !ip.is_empty() {
ips.push(ip);
push_ip(&mut ips, std::str::from_utf8(&out.stdout).unwrap_or(""));
}
}
}
if let Ok(out) = std::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "scope", "global"])
.output()
{
if out.status.success() {
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut parts = line.split_whitespace();
let iface = parts.nth(1).unwrap_or("");
if skip_iface(iface) {
continue;
}
while let Some(tok) = parts.next() {
if tok == "inet" {
if let Some(cidr) = parts.next() {
push_ip(&mut ips, cidr);
}
break;
}
}
}
}
}
if ips.is_empty() {
if let Ok(out) = std::process::Command::new("hostname").arg("-I").output() {
if out.status.success() {
for tok in String::from_utf8_lossy(&out.stdout).split_whitespace() {
push_ip(&mut ips, tok);
}
}
}
@ -53,6 +91,69 @@ pub fn lan_ips() -> Vec<String> {
ips
}
fn skip_iface(name: &str) -> bool {
let name = name.trim_end_matches(':');
name == "lo"
|| name == "docker0"
|| name.starts_with("br-")
|| name.starts_with("veth")
|| name.starts_with("cni")
|| name.starts_with("flannel")
|| name.starts_with("virbr")
|| name.starts_with("lxc")
}
fn outbound_ipv4() -> Option<String> {
let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?;
sock.connect(SocketAddr::from((Ipv4Addr::new(1, 1, 1, 1), 80)))
.ok()?;
match sock.local_addr().ok()? {
SocketAddr::V4(addr) => {
let ip = addr.ip();
if ip.is_unspecified() || ip.is_loopback() {
None
} else {
Some(ip.to_string())
}
}
SocketAddr::V6(_) => None,
}
}
fn push_ip(ips: &mut Vec<String>, raw: &str) {
let trimmed = raw.trim().split('/').next().unwrap_or("").trim();
if trimmed.is_empty() {
return;
}
let Ok(addr) = trimmed.parse::<IpAddr>() else {
return;
};
if addr.is_loopback() || addr.is_unspecified() || addr.is_multicast() {
return;
}
match addr {
IpAddr::V4(v4) if v4.is_link_local() => return,
IpAddr::V6(v6) if v6.is_unicast_link_local() => return,
_ => {}
}
let text = addr.to_string();
if !ips.iter().any(|existing| existing == &text) {
ips.push(text);
}
}
fn apply_validity(params: &mut CertificateParams, days: i64) {
let today = Utc::now().date_naive();
let start = today
.checked_sub_signed(Duration::days(1))
.unwrap_or(today);
let end = today
.checked_add_signed(Duration::days(days))
.unwrap_or(today);
params.not_before = date_time_ymd(start.year(), start.month() as u8, start.day() as u8);
params.not_after = date_time_ymd(end.year(), end.month() as u8, end.day() as u8);
}
fn hostnames() -> Vec<String> {
let mut names = vec!["localhost".into()];
for cmd in [["hostname", "-s"], ["hostname", ""]] {
@ -108,6 +209,13 @@ fn san_list(names: &[String]) -> Vec<SanType> {
pub fn ensure() -> Result<TlsMaterial> {
let dir = cert_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let format_path = dir.join("format");
let current_format = std::fs::read_to_string(&format_path).unwrap_or_default();
if current_format.trim() != MATERIAL_FORMAT {
for name in ["ca.pem", "ca.key", "server.pem", "server.key", "sans.txt"] {
let _ = std::fs::remove_file(dir.join(name));
}
}
let ca_pem_path = dir.join("ca.pem");
let ca_key_path = dir.join("ca.key");
let cert_path = dir.join("server.pem");
@ -159,6 +267,8 @@ pub fn ensure() -> Result<TlsMaterial> {
(pem, key)
};
std::fs::write(&format_path, MATERIAL_FORMAT).ok();
Ok(TlsMaterial {
ca_pem,
cert_pem,
@ -172,6 +282,7 @@ fn issue_ca() -> Result<(String, String)> {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
apply_validity(&mut params, CA_VALID_DAYS);
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "LazyBoy Local CA");
params.distinguished_name = dn;
@ -194,11 +305,10 @@ fn issue_server(ca_pem: &str, ca_key_pem: &str, names: &[String]) -> Result<(Str
.collect();
let mut params = CertificateParams::new(dns).context("server certificate names")?;
params.subject_alt_names = san_list(names);
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
// ECDSA cannot key-encipher; a critical KeyEncipherment bit makes NSS reject the cert.
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
apply_validity(&mut params, SERVER_VALID_DAYS);
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "LazyBoy");
params.distinguished_name = dn;
@ -213,13 +323,7 @@ pub fn server_config(material: &TlsMaterial) -> Result<Arc<ServerConfig>> {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut certs = Vec::new();
for item in rustls_pemfile::certs(&mut material.cert_pem.as_bytes()) {
certs.push(CertificateDer::from(item.context("parse server cert")?));
}
let mut ca_bytes = material.ca_pem.as_bytes();
if let Ok(ca) = rustls_pemfile::certs(&mut ca_bytes).collect::<Result<Vec<_>, _>>() {
for item in ca {
certs.push(CertificateDer::from(item));
}
certs.push(item.context("parse server cert")?);
}
let mut key_bytes = material.key_pem.as_bytes();
let mut keys = rustls_pemfile::pkcs8_private_keys(&mut key_bytes);
@ -274,58 +378,6 @@ fn dirs_login_keychain() -> Option<String> {
}
}
pub fn https_location(host: &str, path: &str, port: u16) -> String {
let host = host.trim();
let path = if path.is_empty() { "/" } else { path };
let with_port = if host.starts_with('[') {
if host.rfind("]:").is_some() {
host.to_string()
} else {
format!("{host}:{port}")
}
} else if host.matches(':').count() == 1 {
host.to_string()
} else {
format!("{host}:{port}")
};
format!("https://{with_port}{path}")
}
pub async fn redirect_plaintext(stream: tokio::net::TcpStream, port: u16) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let mut reader = BufReader::new(stream);
let mut first = String::new();
if reader.read_line(&mut first).await.unwrap_or(0) == 0 {
return;
}
let path = first
.split_whitespace()
.nth(1)
.unwrap_or("/")
.to_string();
let mut host = String::new();
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).await.unwrap_or(0);
if n == 0 || line == "\r\n" || line == "\n" {
break;
}
if let Some(rest) = line.strip_prefix("Host:") {
host = rest.trim().to_string();
}
}
if host.is_empty() {
host = format!("127.0.0.1:{port}");
}
let location = https_location(&host, &path, port);
let response = format!(
"HTTP/1.1 308 Permanent Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let mut stream = reader.into_inner();
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
pub fn is_tls_client_hello(first: u8) -> bool {
first == 0x16
}
@ -334,6 +386,23 @@ pub fn is_tls_client_hello(first: u8) -> bool {
mod tests {
use super::*;
fn https_location(host: &str, path: &str, port: u16) -> String {
let host = host.trim();
let path = if path.is_empty() { "/" } else { path };
let with_port = if host.starts_with('[') {
if host.rfind("]:").is_some() {
host.to_string()
} else {
format!("{host}:{port}")
}
} else if host.matches(':').count() == 1 {
host.to_string()
} else {
format!("{host}:{port}")
};
format!("https://{with_port}{path}")
}
#[test]
fn https_location_keeps_port() {
assert_eq!(
@ -362,4 +431,49 @@ mod tests {
};
server_config(&material).unwrap();
}
#[test]
fn issued_certs_avoid_rcgen_default_dates_that_break_firefox() {
let (ca, ca_key) = issue_ca().unwrap();
let ca_params = CertificateParams::from_ca_cert_pem(&ca).unwrap();
assert!(
ca_params.not_after < date_time_ymd(2100, 1, 1),
"Firefox NSS reports SEC_ERROR_BAD_SIGNATURE for rcgen's year-4096 notAfter"
);
assert!(ca_params.not_before > date_time_ymd(2020, 1, 1));
let (cert, _) = issue_server(
&ca,
&ca_key,
&["localhost".into(), "10.0.33.1".into()],
)
.unwrap();
let params = CertificateParams::from_ca_cert_pem(&cert).unwrap();
assert!(params.not_after < date_time_ymd(2100, 1, 1));
assert!(params.not_before > date_time_ymd(2020, 1, 1));
assert!(
params
.subject_alt_names
.iter()
.any(|san| matches!(san, SanType::IpAddress(ip) if ip.to_string() == "10.0.33.1")),
"LAN IP must be an IP SAN, not only a DNS label: {:?}",
params.subject_alt_names
);
assert!(!params.key_usages.contains(&KeyUsagePurpose::KeyEncipherment));
}
#[test]
fn outbound_ip_is_listed_when_routing_exists() {
let Some(ip) = outbound_ipv4() else {
return;
};
let ips = lan_ips();
assert!(
ips.contains(&ip),
"lan_ips={ips:?} should include outbound {ip}"
);
assert!(
desired_names().contains(&ip),
"certificate SAN must include LAN IP {ip}"
);
}
}

View File

@ -224,7 +224,7 @@ impl Subagents {
}
if kind.is_computer_use() && self.has_running_computer_use() {
return Err(anyhow!(
"A computerUse subagent is already using the box's desktop. Only one can run at a time."
"A computerUse subagent is already using this agent's desktop. Only one can run at a time on the same computer."
));
}
// Path ladder: pixels last. Require a lower rung (or explicit force) before computerUse.
@ -239,6 +239,9 @@ impl Subagents {
.unwrap()
.clone()
.ok_or_else(|| anyhow!("no model client for subagent"))?;
if kind.is_computer_use() {
parent.box_hub.try_begin_computer_use()?;
}
let id = uuid::Uuid::new_v4().to_string();
let title = title
.map(str::trim)
@ -325,6 +328,9 @@ impl Subagents {
.await;
input.end();
child.shutdown().await;
if kind_run.is_computer_use() {
child.box_hub.end_computer_use();
}
let (kind, message) = match verdict {
Ok(v) => (v.kind().to_string(), v.message().to_string()),
Err(e) => ("failed".into(), format!("{e:#}")),
@ -357,7 +363,7 @@ impl Subagents {
"kind": kind.as_str(),
"status": "running",
"instruction": if kind.is_computer_use() {
"computerUse subagent started in the background and owns the desktop. Do not click or type on the screen; a screenshot to check in is allowed. Do not wait or poll; you will be revived with its result. Only one computerUse can run at a time."
"computerUse subagent started in the background and owns this agent's desktop. Do not click or type on the screen; a screenshot to check in is allowed. Do not wait or poll; you will be revived with its result. Only one computerUse can run at a time on the same computer."
} else {
"Subagent started in the background. Do not wait or poll for completion. Keep working or end the turn with no tool calls; you will be revived automatically with its result."
}

View File

@ -587,6 +587,7 @@ impl Service {
self.chats.lock().unwrap().remove(&a.id);
self.chats.lock().unwrap().remove(&a.name);
let deleted = self.store.delete_agent(&a.id)?;
let _ = crate::BoxPool::global().drop_seat(&deleted.id).await;
Ok(json!({"ok": true, "id": deleted.id, "name": deleted.name}))
}
"tasks" => Ok(json!(self

View File

@ -1,5 +1,5 @@
use super::service::Service;
use super::store::AgentIdentity;
use super::store::{AgentIdentity, Store, TaskRecord};
use crate::{AgentEvent, AgentVerdict, ChatMessage, InputBroker, Runtime, Session, ToolContext};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
@ -334,6 +334,7 @@ impl Service {
None => {
let mut ctx = ToolContext::new(&session.cwd).with_runtime(runtime.clone());
ctx.team = Some(self.context(&t.agent_id, Some(&t.id)));
ctx.box_hub = crate::BoxPool::global().hub(&seat_id_for_task(&self.store, &t));
ctx
}
};
@ -498,6 +499,7 @@ impl Service {
s.store.save_conversation(&agent_id, &session.messages)
});
let mut ctx = ToolContext::new(a.cwd).with_runtime(runtime.clone());
ctx.box_hub = crate::BoxPool::global().hub(&a.id);
let team = self.context(agent, None);
if self.store.direct_user_chat(id)? {
*team.user_reply.lock().unwrap() = Some(body.to_owned());
@ -624,3 +626,10 @@ pub(super) fn recorded_evidence(messages: &[ChatMessage]) -> Vec<Value> {
}
observations.into_iter().rev().take(6).collect()
}
fn seat_id_for_task(store: &Store, t: &TaskRecord) -> String {
match store.agent(&t.agent_id) {
Ok(agent) if agent.temporary => t.owner_id.clone(),
_ => t.agent_id.clone(),
}
}

View File

@ -701,6 +701,7 @@ async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str)
let ready = ctx.box_hub.ensure_ready().await?;
let viewer = ready["viewer_url"]
.as_str()
.or(ready["host_viewer_url"].as_str())
.unwrap_or("")
.to_string();
ctx.runtime.park_question(&json!({
@ -1015,7 +1016,7 @@ fn extra_tool_definitions() -> Vec<Value> {
def("external_glob","Find files by name glob (e.g. **/*.rs) under a path.",json!({"pattern":{"type":"string"},"path":{"type":"string"},"limit":{"type":"integer"}}),json!(["pattern"])),
def("web_search","Search public web via the configured remote service. Does not open a browser or use browser logins.",json!({"gap":{"type":"integer","minimum":0},"searchTerm":{"type":"string"},"explanation":{"type":"string"}}),json!(["searchTerm"])),
def("web_fetch","Fast anonymous HTTP GET of a public URL; HTML is reduced to readable text. No cookies or JavaScript. Cached briefly — do not refetch the same URL. If content_kind is blocked_plain_http, the page needs browser_* (JS/login), not another fetch. At most one web_search per round; fetch several URLs in the same batch.",json!({"gap":{"type":"integer","minimum":0},"url":{"type":"string"},"max_bytes":{"type":"integer"}}),json!(["url"])),
def("spawn_subagent","Start a background subagent for a self-contained chunk of work. Returns immediately with subagent_id. Do not wait or poll; keep working or end the turn — you are revived automatically when it finishes. kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot/click/move/drag/type/key/scroll/wait; only one computerUse may run at a time because they share the screen. Path ladder: do not spawn computerUse for ordinary web until web_fetch/web_search and/or browser_* (or call_mcp_tool) have been tried this turn; set force=true for native GUI, file dialogs, drag, or sites that already defeated page-level automation.",json!({"goal":{"type":"string"},"title":{"type":"string"},"kind":{"type":"string","enum":["general","computerUse"],"description":"general (default) or computerUse"},"subagent_type":{"type":"string","description":"Alias of kind (Grok Bot Task subagent_type)"},"force":{"type":"boolean","description":"Bypass path-ladder gate for computerUse when the task is a native GUI, file dialog, drag, or a site that already defeated DOM automation"}}),json!(["goal"])),
def("spawn_subagent","Start a background subagent for a self-contained chunk of work. Returns immediately with subagent_id. Do not wait or poll; keep working or end the turn — you are revived automatically when it finishes. kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot/click/move/drag/type/key/scroll/wait; only one computerUse may run at a time on this agent's computer. Path ladder: do not spawn computerUse for ordinary web until web_fetch/web_search and/or browser_* (or call_mcp_tool) have been tried this turn; set force=true for native GUI, file dialogs, drag, or sites that already defeated page-level automation.",json!({"goal":{"type":"string"},"title":{"type":"string"},"kind":{"type":"string","enum":["general","computerUse"],"description":"general (default) or computerUse"},"subagent_type":{"type":"string","description":"Alias of kind (Grok Bot Task subagent_type)"},"force":{"type":"boolean","description":"Bypass path-ladder gate for computerUse when the task is a native GUI, file dialog, drag, or a site that already defeated DOM automation"}}),json!(["goal"])),
def("check_subagent","Inspect a running background subagent (status, elapsed time, recent tools). Omit subagent_id to list all. Not for polling completion.",json!({"subagent_id":{"type":"string"}}),json!([])),
def("message_subagent","Inject an instruction into a running subagent without aborting it. It keeps its context.",json!({"subagent_id":{"type":"string"},"message":{"type":"string"}}),json!(["subagent_id","message"])),
def("stop_subagent","Abort a running background subagent.",json!({"subagent_id":{"type":"string"}}),json!(["subagent_id"])),
@ -1373,10 +1374,11 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Val
if let Some(url) = ctx.last_browser_url_value() {
let restored = ctx
.browser
.request(
.request_on(
&ctx.cwd,
ctx.runtime.profile_dir(),
json!({"op":"navigate","url":url}),
Some(ctx.box_hub.seat_id()),
)
.await?;
if restored["ok"] != true {
@ -1389,10 +1391,11 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Val
required_text(args, "reason")?;
let prep = ctx
.browser
.request(
.request_on(
&ctx.cwd,
ctx.runtime.profile_dir(),
json!({"op":"handoff_prepare"}),
Some(ctx.box_hub.seat_id()),
)
.await?;
if prep["ok"] != true {
@ -1443,12 +1446,12 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Val
}
let mut result = ctx
.browser
.request(&ctx.cwd, ctx.runtime.profile_dir(), req)
.request_on(&ctx.cwd, ctx.runtime.profile_dir(), req, Some(ctx.box_hub.seat_id()))
.await?;
result["surface"] = json!(if crate::browser_client::local_browser_enabled() { "local_browser" } else { "box_browser" });
if !crate::browser_client::local_browser_enabled() {
result["profile"] = json!("/home/box/chrome-profile");
result["viewer_url"] = json!(BoxHub::viewer_url());
result["viewer_url"] = json!(ctx.box_hub.web_viewer_url());
}
browser::update_last_url(&ctx.last_browser_url, &result);
*ctx.runtime.browser_url.lock().unwrap() = ctx.last_browser_url_value();

View File

@ -2,7 +2,7 @@
//! Talks to the named-agent team daemon over the existing Unix RPC.
use crate::team;
use crate::BoxHub;
use crate::{BoxPool, SESSION_SEAT};
use anyhow::{Context, Result};
use axum::body::{Body, Bytes};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
@ -36,7 +36,7 @@ const FALLBACK_HTML: &str = r#"<!doctype html>
struct App {
token: Option<String>,
cwd: PathBuf,
box_hub: std::sync::Arc<BoxHub>,
box_pool: std::sync::Arc<BoxPool>,
ca_pem: Option<String>,
}
@ -79,7 +79,7 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
let app = App {
token,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
box_hub: BoxHub::new(),
box_pool: BoxPool::global(),
ca_pem: tls.as_ref().map(|m| m.ca_pem.clone()),
};
let cors = CorsLayer::new()
@ -106,6 +106,10 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
.route("/api/agents/:id/events", get(api_events))
.route("/api/agents/:id/activity", get(api_activity))
.route("/api/agents/:id/handover", post(api_handover))
.route(
"/api/agents/:id/computer",
get(api_agent_computer).post(api_agent_computer_action),
)
.route("/api/computer", get(api_computer).post(api_computer_action))
.route("/lazyboy-ca.crt", get(api_ca_cert))
.route("/novnc", any(novnc_proxy))
@ -128,15 +132,12 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
})?;
let bound = listener.local_addr()?;
if let Some(material) = tls {
eprintln!("LazyBoy https://127.0.0.1:{}", bound.port());
eprintln!("LazyBoy http://127.0.0.1:{} https 同埠)", bound.port());
for lan in crate::local_tls::lan_ips() {
eprintln!(
"手機同一個 Wi-Fi https://{lan}:{}",
bound.port()
);
eprintln!("手機同一個 Wi-Fi http://{lan}:{}", bound.port());
}
eprintln!(
"第一次請安裝本機憑證(否則瀏覽器會顯示「不是安全連線」): https://127.0.0.1:{}/lazyboy-ca.crt",
"要裝 PWA鎖頭再走 https並安裝憑證 http://127.0.0.1:{}/lazyboy-ca.crt",
bound.port()
);
eprintln!("憑證涵蓋 {}", material.names.join(", "));
@ -486,15 +487,20 @@ const WEB_VIEWER: &str = "/novnc/vnc.html?autoconnect=true&resize=off&reconnect=
fn computer_response(result: anyhow::Result<Value>) -> Json<Value> {
match result {
Ok(ready) => Json(json!({
"ready": true,
"viewer_url": WEB_VIEWER,
"ready": ready.get("ready").and_then(|v| v.as_bool()).unwrap_or(true),
"state": ready.get("state").cloned().unwrap_or(json!("ready")),
"viewer_url": ready.get("viewer_url").cloned().unwrap_or(json!(WEB_VIEWER)),
"workspace": ready.get("workspace"),
"revision": ready.get("revision"),
"action": ready.get("action"),
"updated": ready.get("updated"),
"seat": ready.get("seat"),
"crowded": ready.get("crowded"),
"error": ready.get("error"),
})),
Err(error) => Json(json!({
"ready": false,
"state": "error",
"error": error.to_string(),
"viewer_url": WEB_VIEWER,
})),
@ -506,7 +512,42 @@ async fn api_computer(
headers: HeaderMap,
) -> Result<Json<Value>, StatusCode> {
authorize(&app, &headers)?;
Ok(computer_response(app.box_hub.ensure_ready().await))
Ok(Json(app.box_pool.inspect(SESSION_SEAT).await))
}
async fn resolve_agent_seat(id: &str) -> Result<String, (StatusCode, Json<Value>)> {
let value = rpc(json!({"op": "get", "agent": id})).await?;
Ok(value["id"].as_str().unwrap_or(id).to_string())
}
async fn api_agent_computer(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
authorize(&app, &headers).map_err(|s| api_err(s, "unauthorized"))?;
let seat = resolve_agent_seat(&id).await?;
Ok(Json(app.box_pool.inspect(&seat).await))
}
async fn api_agent_computer_action(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<ComputerBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
authorize(&app, &headers).map_err(|s| api_err(s, "unauthorized"))?;
let seat = resolve_agent_seat(&id).await?;
let kind = match computer_action_kind(&body.action) {
Ok(kind) => kind,
Err(message) => return Err(api_err(StatusCode::BAD_REQUEST, &message)),
};
let result = match kind {
"restart" => app.box_pool.restart(&seat).await,
"update" => app.box_pool.update(&seat).await,
_ => app.box_pool.start(&seat).await,
};
Ok(computer_response(result))
}
#[derive(Deserialize, Default)]
@ -537,9 +578,9 @@ async fn api_computer_action(
Err(message) => return Err(api_err(StatusCode::BAD_REQUEST, &message)),
};
let result = match kind {
"restart" => app.box_hub.restart().await,
"update" => app.box_hub.update().await,
_ => app.box_hub.ensure_ready().await,
"restart" => app.box_pool.restart(SESSION_SEAT).await,
"update" => app.box_pool.update(SESSION_SEAT).await,
_ => app.box_pool.start(SESSION_SEAT).await,
};
Ok(computer_response(result))
}
@ -573,6 +614,37 @@ async fn api_ca_cert(State(app): State<App>) -> Response {
}
}
fn is_novnc_asset(first: &str) -> bool {
first == "vnc.html"
|| first == "vnc_lite.html"
|| first == "websockify"
|| first == "app"
|| first == "core"
|| first == "vendor"
|| first.contains('.')
}
fn split_novnc_target(rest: &str) -> (Option<String>, String) {
let rest = if rest.is_empty() { "/" } else { rest };
let (path, query) = match rest.split_once('?') {
Some((path, query)) => (path, Some(query)),
None => (rest, None),
};
let trimmed = path.trim_start_matches('/');
let mut parts = trimmed.splitn(2, '/');
let first = parts.next().unwrap_or("");
if first.is_empty() || is_novnc_asset(first) {
return (None, rest.to_string());
}
let tail = parts.next().unwrap_or("");
let mut rebuilt = format!("/{tail}");
if let Some(query) = query {
rebuilt.push('?');
rebuilt.push_str(query);
}
(Some(first.to_string()), rebuilt)
}
async fn novnc_proxy(State(app): State<App>, req: Request) -> Response {
let path_and_query = req
.uri()
@ -588,9 +660,20 @@ async fn novnc_proxy(State(app): State<App>, req: Request) -> Response {
} else {
rest
};
let (seat, rest) = split_novnc_target(&rest);
if is_vnc_html(&rest) && novnc_opened_as_tab(req.headers()) {
return Redirect::temporary("/").into_response();
}
let seat_id = seat.as_deref().unwrap_or(SESSION_SEAT);
let port = app
.box_pool
.hub(seat_id)
.host_port()
.await
.unwrap_or(if seat.is_none() { 6080 } else { 0 });
if port == 0 {
return (StatusCode::BAD_GATEWAY, "this computer is not running").into_response();
}
if req
.headers()
.get(header::UPGRADE)
@ -599,19 +682,19 @@ async fn novnc_proxy(State(app): State<App>, req: Request) -> Response {
{
return match WebSocketUpgrade::from_request(req, &app).await {
Ok(upgrade) => upgrade
.on_upgrade(move |socket| proxy_vnc_ws(socket, rest))
.on_upgrade(move |socket| proxy_vnc_ws(socket, rest, port))
.into_response(),
Err(err) => err.into_response(),
};
}
match proxy_vnc_http(req, &rest).await {
match proxy_vnc_http(req, &rest, port).await {
Ok(resp) => resp,
Err(_) => (StatusCode::BAD_GATEWAY, "computer viewer proxy failed").into_response(),
}
}
async fn proxy_vnc_http(req: Request, rest: &str) -> Result<Response, anyhow::Error> {
let url = format!("http://127.0.0.1:6080{rest}");
async fn proxy_vnc_http(req: Request, rest: &str, port: u16) -> Result<Response, anyhow::Error> {
let url = format!("http://127.0.0.1:{port}{rest}");
let method = req.method().clone();
let client = reqwest::Client::new();
let mut builder = client.request(method, &url);
@ -672,9 +755,9 @@ async fn proxy_vnc_http(req: Request, rest: &str) -> Result<Response, anyhow::Er
.unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()))
}
async fn proxy_vnc_ws(client: WebSocket, rest: String) {
async fn proxy_vnc_ws(client: WebSocket, rest: String, port: u16) {
let path = rest.split('?').next().unwrap_or("/websockify");
let url = format!("ws://127.0.0.1:6080{path}");
let url = format!("ws://127.0.0.1:{port}{path}");
let Ok((upstream, _)) = tokio_tungstenite::connect_async(url).await else {
let _ = client.close().await;
return;
@ -725,7 +808,6 @@ async fn serve_tls_mux(
.install_default()
.ok();
let acceptor = tokio_rustls::TlsAcceptor::from(crate::local_tls::server_config(material)?);
let port = listener.local_addr()?.port();
loop {
let (stream, _) = listener.accept().await?;
let acceptor = acceptor.clone();
@ -733,34 +815,37 @@ async fn serve_tls_mux(
tokio::spawn(async move {
let mut head = [0u8; 1];
match stream.peek(&mut head).await {
Ok(0) => return,
Ok(_) if crate::local_tls::is_tls_client_hello(head[0]) => {}
Ok(_) => {
crate::local_tls::redirect_plaintext(stream, port).await;
return;
Ok(0) | Err(_) => {}
Ok(_) if crate::local_tls::is_tls_client_hello(head[0]) => {
if let Ok(tls_stream) = acceptor.accept(stream).await {
serve_hyper(tls_stream, router).await;
}
}
Err(_) => return,
Ok(_) => serve_hyper(stream, router).await,
}
let Ok(tls_stream) = acceptor.accept(stream).await else {
return;
};
let io = hyper_util::rt::TokioIo::new(tls_stream);
let service = hyper::service::service_fn(move |request: axum::extract::Request<hyper::body::Incoming>| {
let router = router.clone();
async move {
tower::ServiceExt::oneshot(router, request.map(axum::body::Body::new)).await
}
});
let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection_with_upgrades(io, service)
.await;
});
}
}
async fn serve_hyper<I>(stream: I, router: Router)
where
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let io = hyper_util::rt::TokioIo::new(stream);
let service = hyper::service::service_fn(
move |request: axum::extract::Request<hyper::body::Incoming>| {
let router = router.clone();
async move { tower::ServiceExt::oneshot(router, request.map(axum::body::Body::new)).await }
},
);
let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection_with_upgrades(io, service)
.await;
}
#[cfg(test)]
mod tests {
use super::{computer_action_kind, is_vnc_html, novnc_opened_as_tab};
use super::{computer_action_kind, is_vnc_html, novnc_opened_as_tab, split_novnc_target};
use axum::http::{HeaderMap, HeaderValue};
#[test]
@ -782,6 +867,21 @@ mod tests {
assert!(!is_vnc_html("/websockify"));
}
#[test]
fn agent_novnc_path_is_stripped() {
let (seat, rest) = split_novnc_target(
"/11111111-1111-1111-1111-111111111111/vnc.html?autoconnect=true",
);
assert_eq!(seat.as_deref(), Some("11111111-1111-1111-1111-111111111111"));
assert_eq!(rest, "/vnc.html?autoconnect=true");
let (seat, rest) = split_novnc_target("/vnc.html?autoconnect=true");
assert_eq!(seat, None);
assert_eq!(rest, "/vnc.html?autoconnect=true");
let (seat, rest) = split_novnc_target("/11111111-1111-1111-1111-111111111111/websockify");
assert_eq!(seat.as_deref(), Some("11111111-1111-1111-1111-111111111111"));
assert_eq!(rest, "/websockify");
}
#[test]
fn vnc_direct_tab_uses_fetch_dest() {
let mut headers = HeaderMap::new();

View File

@ -155,7 +155,7 @@ ENV:
LAZYBOY_WEB_PROVIDER xai = enable native search on an xAI-compatible proxy
LAZYBOY_WEB_HOST web UI bind (default 0.0.0.0)
LAZYBOY_WEB_PORT web UI port (default 8787)
LAZYBOY_TLS 0 = plain HTTP (default is local HTTPS)
LAZYBOY_TLS 0 = plain HTTP only (default HTTP+HTTPS on the same port)
LAZYBOY_WEB_TOKEN optional bearer token for the web UI
LAZYBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort
LAZYBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny
@ -178,7 +178,12 @@ During a task: enter additional instructions to steer the next step.
async fn cmd_computer() -> Result<()> {
let ready = BoxHub::new().ensure_ready().await?;
println!("我的電腦已就緒");
println!("{}", BoxHub::viewer_url());
println!(
"{}",
ready["host_viewer_url"]
.as_str()
.unwrap_or(&BoxHub::viewer_url())
);
if let Some(msg) = ready["instruction"].as_str() {
eprintln!("{msg}");
}

View File

@ -12,7 +12,7 @@ python3 tests/cli_flow.py
cargo run -p lazyboy -- smoke
```
Coverage: answer/done/blocked/budget/failed; invalid and truncated model responses; complete call/result pairing; progress before tools; commentary-only loop protection; plan updates and unfinished-step validation; changed observations; context preservation; segmented reading, literal search and unique edits; workspace symlink checks; old session compatibility and interrupted-history recovery; incremental process output and stdin; helper deadline, mismatched ID, cancellation and restart; parent cannot call `computer`; `spawn_subagent kind=computerUse` is one-at-a-time and the child is the only agent offered `computer`; invalid click coordinates fail closed. Docker click+screenshot is `#[ignore]` (`computer_use_click_and_screenshot_on_box`).
Coverage: answer/done/blocked/budget/failed; invalid and truncated model responses; complete call/result pairing; progress before tools; commentary-only loop protection; plan updates and unfinished-step validation; changed observations; context preservation; segmented reading, literal search and unique edits; workspace symlink checks; old session compatibility and interrupted-history recovery; incremental process output and stdin; helper deadline, mismatched ID, cancellation and restart; parent cannot call `computer`; `spawn_subagent kind=computerUse` is one-at-a-time per agent computer and the child is the only agent offered `computer`; invalid click coordinates fail closed. Docker click+screenshot is `#[ignore]` (`computer_use_click_and_screenshot_on_box`).
## Interactive runtime integration

View File

@ -50,7 +50,7 @@ Ctrl-C 或 `/stop` 取消模型/工具/人工等待。命令以 process grou
`lazyboy web``:8787``LAZYBOY_WEB_PORT`)提供 Grok Bot 風格對話網頁與 PWA。手機與電腦同一 Wi-Fi 時,用終端機印出的 LAN URL 開啟SafariChrome「加入主畫面」即可全螢幕當 app。`/novnc` 同源代理 Docker 桌面,所以手機不必直連 6080。
`spawn_subagent` 立刻返回。父回合若在子 agent 或背景命令仍在跑時以無工具結束runtime 會等它完成、灌入 revival 訊息、再請模型繼續;不要用 `check_subagent` 輪詢完成。子 agent 不能再派子 agent。`LAZYBOY_SUBAGENT_MAX_ROUNDS` 預設 128。`kind=computerUse` 把桌面點擊交給子 agent`computer`screenshot/click/move/drag/type/key/scroll/waitxdotool `DISPLAY=:1`);同一時間只能有一個 computerUse。父層只有唯讀 `screenshot`。人類登入仍走 `request_box_help`
`spawn_subagent` 立刻返回。父回合若在子 agent 或背景命令仍在跑時以無工具結束runtime 會等它完成、灌入 revival 訊息、再請模型繼續;不要用 `check_subagent` 輪詢完成。子 agent 不能再派子 agent。`LAZYBOY_SUBAGENT_MAX_ROUNDS` 預設 128。`kind=computerUse` 把桌面點擊交給子 agent`computer`screenshot/click/move/drag/type/key/scroll/waitxdotool `DISPLAY=:1`);同一座位一次只能有一個 computerUse不同 named agent 的電腦互不搶螢幕。父層只有唯讀 `screenshot`。人類登入仍走 `request_box_help`Web 開 named agent 會啟動該座位的 Docker 桌面並顯示就緒或錯誤。
HTTP 連線期限 15 秒、單次請求 180 秒helper 回應期限 60 秒;網頁條件等待最多 30 秒命令預設最多十分鐘legacy shell 為 30 秒。等待時每 20 秒顯示實際階段與經過時間,不呼叫模型製造更新。

View File

@ -33,7 +33,7 @@ Checkpoint before and after tools, retain plan/pending questions/command metadat
## Boundaries
Current target: macOS CLI plus a split web stack — Rust `lazyboy serve` is JSON/SSE API for **named Agents** (not session files); the Vite/React app in `web/` is the Grok Bot-shaped shell (agent sidebar, conversation, composer, computer overlay, PWA). Optional Playwright DOM browser and Docker box desktop. Not an Electron clone. Persistent named agents use a local daemon, private memory and a background task scheduler; see [TEAM](TEAM.md). Desktop pixel control is a `computerUse` subagent (`computer` via xdotool); the parent cannot click. No separate search key, Electron UI, PTY, CUA, or per-agent virtual monitors in this version. Browser-only canvas apps and arbitrary natural-language task correctness remain outside what this MVP can guarantee.
Current target: macOS CLI plus a split web stack — Rust `lazyboy serve` is JSON/SSE API for **named Agents** (not session files); the Vite/React app in `web/` is the Grok Bot-shaped shell (agent sidebar, conversation, composer, computer overlay, PWA). Optional Playwright DOM browser and Docker box desktop. Not an Electron clone. Persistent named agents use a local daemon, private memory and a background task scheduler; see [TEAM](TEAM.md). Desktop pixel control is a `computerUse` subagent (`computer` via xdotool); the parent cannot click. Each named agent has its own Docker desktop; opening the agent starts that seat and the UI reports whether it became ready. No separate search key, Electron UI, PTY, or CUA in this version. Browser-only canvas apps and arbitrary natural-language task correctness remain outside what this MVP can guarantee.
Legacy `chat` stays a no-tools streaming chat. `shell` stays available with a 30-second deadline. Synchronous human-wait library helpers are retained for AUTO/offline compatibility; interactive callers must supply the runtime input broker.

View File

@ -11,7 +11,7 @@
| 公開抓頁 | host → Cursor AiService.RunWebFetch | host 直接匿名 HTTP GETHTML 轉文字、同 URL 十分鐘快取、redirect 重新檢查公開主機);只有網站拒絕純 HTTP 時才退回 xAI 遠端瀏覽,並明確標記 model_rendered_web_content 與 fallback_reason |
| 登入網頁 | box Chrome | Docker ChromiumDOM helper 也在 Docker |
| 人工登入 | RequestBoxHelp → agent 桌面 | request_box_helpbrowser_handoff → 同一個 Docker Chromium |
| 桌面像素操作 | 父層 ScreenshotcomputerUse 子 agent 才有 Computerclick/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`xdotool `DISPLAY=:1`)。一次一個 computerUse |
| 桌面像素操作 | 父層 ScreenshotcomputerUse 子 agent 才有 Computerclick/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`xdotool `DISPLAY=:1`)。每個 named agent 一台 Docker 桌面;同一台電腦一次一個 computerUse |
| 瀏覽器資料 | /home/box/chrome-profile另有 box store 同步 | /home/box/chrome-profilelazyboy-box-home volume 持久保存 |
**WebSearchWebFetch 不會讀取任何瀏覽器 profile 或 cookies。** 後端授權 token、模型 API key、網站登入 cookies 是三種不同資料。MCP 仍使用 connector 自己的授權,也不會自動取得 Chromium cookies。
@ -37,7 +37,7 @@ Gateway 需要提供 JSON Connect `POST /aiserver.v1.AiService/RunWebSearch`s
## 尚未等同參考版的部分
- 參考版提供 per-agent monitordesktop此版仍是單一 Docker 桌面DISPLAY=:1computerUse 以「一次一個」排他,不具有獨立螢幕隔離。執行器是 xdotool不是參考版 box 內的 protobuf ComputerUse executor / CUA。
- 參考版在同一台 box 上分 per-agent monitor。此版改成每個 named agent 一個 Docker 容器(同一映像),各自 DISPLAY=:1、Chrome profile 與 `/workspace`。臨時 worker 用發起任務的 owner 座位。computerUse 按座位排他,不是全域一台螢幕。執行器仍是 xdotool不是參考版 box 內的 protobuf ComputerUse executor / CUA。
- 參考版有 cloud box storeChrome session snapshot同步到遠端儲存此版使用本機 Docker volume未實作該雲端備份。
- 參考版 local-docker connector 可掛入 `.codex``.claude` 的唯讀 CLI 認證,並將 host runner 放進容器;此版 host/model/MCP 編排仍在本地,未自動掛入這些私人目錄。
- 本地工具沿用 LazyBoy2 既有工作區授權規則,沒有複製參考版每次 ExternalShell 的批准卡 UI。
@ -64,6 +64,6 @@ Box 執行期行為:截圖前會等 `SCREENSHOT_SETTLE_MS` 讓桌面重繪;
一般工具名 `shell``read``await_shell` 一律指向 box`box_shell``box_read``box_await` 保留為舊 box 呼叫的別名。本地檔案與命令工具全部改成 `external_*`;舊模糊名稱如 `exec_command``read_file` 會報錯不會隱含操作本地。Box 命令等待時間到後保留執行,以 `await_shell` 收取結果。關閉桌面瀏覽器不會阻止 shell/read 工作,下一次 browser 工具會重開原 profile。
此處的 box 是本機 Docker Linux 環境,並非另租的雲端主機;檔案與瀏覽器狀態隔離在 containervolume但 CPU、磁碟與 Docker daemon 仍屬於本機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。
此處的 box 是本機 Docker Linux 環境,並非另租的雲端主機;每個 named agent 的檔案與瀏覽器狀態隔離在自己的 containervolume但 CPU、磁碟與 Docker daemon 仍屬於本機。開 agent 會啟動該座位並等到桌面就緒或留下錯誤GET 狀態不會順便開機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。舊的 `lazyboy agent``run``computer` 仍使用全域 `lazyboy-box`
CLI mock-provider 測試也已通過(包含串流工具呼叫、背景 subagentexternal command 回覆與 session 恢復)。

View File

@ -77,7 +77,7 @@ handoff 提供「已完成登入,請檢查」、「登入仍失敗,改做其
- 根任務樹共用 `LAZYBOY_MAX_ROUNDS_TOTAL`,預設 48最後 4 次只供根任務使用。子 task 的請求也計入根計數,不會開一個 agent 就多拿 48 次。恢復不自動補預算;耗盡時需另開有明確範圍的新工作。
- 前景每次回覆最多 12 次模型請求。每個完成聊天回合/持久 agent 的任務最多再排一次記憶整理,失敗不阻擋聊天、不自動重試。這些與根任務執行預算分開計算。
- 同一 canonical 工作區內的工具操作互斥;長指令退出後才釋放鎖。操作不同工作區可並行。活躍指令須先結束或終止,才能等待子 task 或人工回答,避免拿著鎖等待別人工作。
- 同一主 agent 的任務共用固定 Chromium profile包含委派給其他 agent 的工作;不同主 agent 保持隔離。同時只有一個 task 持有瀏覽器handoff 等待期間也不讓其他 worker 操作。`browser_release` 或任務結束會關閉瀏覽器並釋放使用權,保留登入資料。委派瀏覽器子任務前先 release避免互等。
- 每個長期 named agent 有自己的 Docker 電腦(容器、`/workspace`、Chromium profile。開這個 agent 會啟動它的座位並確認桌面就緒。同一 owner 的背景 task 與 computerUse 子 agent 用這台電腦;`delegate_task` 給另一個 named agent 時改用被交辦者的電腦;臨時 `spawn_agent` worker 用發起任務的 owner 座位。同時只有一個 task 持有該座位的瀏覽器handoff 等待期間也不讓其他 worker 操作同一座位。`browser_release` 或任務結束會關閉瀏覽器並釋放使用權,保留登入資料。委派瀏覽器子任務前先 release避免互等。
- 升級首次使用時,固定 profile 優先連結至仍有 handoff 問題的舊 task profile否則採該主 agent 最近使用的 profile保留原資料不合併不同 profile 的登入帳號。沒有舊 profile 才建立新的。一般 Chrome 的登入狀態不會自動匯入。
task 使用 `queued`、`running`、`waiting_input`、`terminal` 狀態;終態另有 done/answer/blocked/budget_exhausted/failed/cancelled/interrupted。停止執行中的 task 先提出取消,工具清理後才進 terminal。沒有連線的人工問題仍保持等待只有明確回答或停止才繼續。

View File

@ -27,6 +27,7 @@ try {
return route.fulfill({ contentType: "text/event-stream",
body: 'data: {"events":[{"id":1,"kind":"runtime","payload":{"type":"waiting"}}]}\n\n' });
}
if (path.endsWith("/computer")) return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } });
if (path === "/api/agents") return route.fulfill({ json: {
agents: [{ id: "owner", name: "測試", running: true }],
} });

View File

@ -74,6 +74,9 @@ try {
if (path.endsWith("/events")) {
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
}
if (path.endsWith("/computer")) {
return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } });
}
return route.fulfill({ json: {} });
});
await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173");

View File

@ -38,6 +38,9 @@ try {
if (path.endsWith("/events")) {
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
}
if (path.endsWith("/computer")) {
return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } });
}
if (path === "/api/agents") {
return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } });
}

View File

@ -3,13 +3,14 @@ import { chromium } from "../tools/playwright/node_modules/playwright/index.mjs"
import assert from "node:assert/strict";
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({serviceWorkers:"block"});
const context = await browser.newContext({serviceWorkers:"block", ignoreHTTPSErrors: true});
const page = await context.newPage();
page.setDefaultTimeout(5000);
page.on("pageerror",e=>console.error(e.message));
await page.addInitScript(() => localStorage.setItem("lazyboy.locale", "zh-Hant"));
let waiting = true;
const submitted = [];
const computerCalls = [];
const activity = () => ({
state: waiting ? "waiting_input" : "running", running: !waiting, queued: false,
active_task_ids: waiting ? [] : ["task"], observed_at_ms: Date.now(),
@ -30,7 +31,10 @@ try {
if (path.endsWith("/messages")) throw new Error("handover must not post ordinary chat");
if (path.endsWith("/events")) return route.fulfill({ contentType:"text/event-stream",
body:'data: {"events":[{"id":1,"kind":"runtime","payload":{"type":"user_progress","message":"正在比對官方規則。"}},{"id":2,"kind":"runtime","payload":{"type":"progress","message":"JavaScript internal scratchpad"}},{"id":3,"kind":"runtime","payload":{"type":"user_progress","message":"正在整理攻略。"}}]}\n\n' });
if (path === "/api/computer") return route.fulfill({ json:{ready:true,viewer_url:"/fixture-viewer"} });
if (path.endsWith("/computer")) {
computerCalls.push({ method: route.request().method(), path });
return route.fulfill({ json:{ready:true,state:"ready",viewer_url:"/fixture-viewer"} });
}
if (path === "/api/agents") return route.fulfill({ json:{agents:[{id:"owner",name:"測試"}]} });
if (path === "/api/agents/owner") return route.fulfill({ json:{id:"owner",name:"測試",expertise:"",preview:"",transcript:[],running:false,activity:activity()} });
return route.fulfill({ json:{} });
@ -42,6 +46,8 @@ try {
assert.equal(await page.locator(".thinking-row .avatar.thinking").count(),0);
assert(await page.locator(".composer textarea").isDisabled());
assert.equal(await page.locator("iframe.desktop-frame").count(),1);
assert(computerCalls.some((call) => call.method === "POST" && call.path === "/api/agents/owner/computer"), JSON.stringify(computerCalls));
assert((await page.locator("iframe.desktop-frame").getAttribute("src") || "").startsWith("/fixture-viewer"));
await page.getByRole("button",{name:"完成並繼續",exact:true}).click();
await page.locator(".handover-card").waitFor({state:"hidden"});
await page.locator(".thinking-row .avatar.thinking").waitFor();

View File

@ -7,7 +7,7 @@ mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ locale: "zh-TW" });
const context = await browser.newContext({ locale: "zh-TW", ignoreHTTPSErrors: true });
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const page = await context.newPage();
page.setDefaultTimeout(8000);
@ -43,6 +43,9 @@ try {
if (path.endsWith("/events")) {
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
}
if (path.endsWith("/computer")) {
return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } });
}
return route.fulfill({ json: {} });
});
await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173");

View File

@ -25,6 +25,9 @@ try {
body: 'data: {"events":[{"id":9,"kind":"reply","payload":{"verdict":"answer","message":"這段 scratchpad 不該出現在聊天裡"}}]}\n\n',
});
}
if (path.endsWith("/computer")) {
return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } });
}
if (path === "/api/agents") {
return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } });
}

42
web/package-lock.json generated
View File

@ -773,9 +773,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -884,9 +881,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -901,9 +895,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -918,9 +909,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -935,9 +923,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -952,9 +937,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -969,9 +951,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -986,9 +965,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1003,9 +979,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1020,9 +993,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1037,9 +1007,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1054,9 +1021,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1071,9 +1035,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1088,9 +1049,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View File

@ -155,10 +155,6 @@ function stepKey(name: string): StepKey | null {
return "stepWorking";
}
function isComputerStep(step: StepKey | null): boolean {
return step === "stepComputer" || step === "stepBrowser" || step === "stepBox" || step === "stepShell";
}
function stepFromProgress(message: string): StepKey | null {
const text = message.trim();
if (/思考中|thinking/i.test(text)) return "thinking";
@ -351,7 +347,7 @@ export function App() {
const [computerBusy, setComputerBusy] = useState<ComputerAction | null>(null);
const [imageFresh, setImageFresh] = useState(false);
const [computerGen, setComputerGen] = useState(0);
const computerOpRef = useRef<ComputerAction | null>(null);
const computerOpRef = useRef<string | null>(null);
const tRef = useRef(t);
tRef.current = t;
const [typing, setTyping] = useState(false);
@ -504,6 +500,51 @@ export function App() {
}
}, [applyTranscript]);
const runComputer = useCallback(async (action: ComputerAction, id?: string) => {
const copy = tRef.current;
const target = id || activeIdRef.current;
if (!target) return;
const op = `${target}:${action}`;
if (computerOpRef.current === op) return;
if (action === "restart" && !window.confirm(copy.restartConfirm)) return;
if (action === "update" && !window.confirm(copy.updateConfirm)) return;
computerOpRef.current = op;
setComputerBusy(action);
setComputerReady(false);
if (action !== "start") setComputerUrl("");
setComputerStatus(copy[computerBusyKey(action)]);
try {
const data = await api.computer(target, action);
if (activeIdRef.current !== target) return;
setComputerReady(Boolean(data.ready));
if (data.ready) {
setComputerGen((n) => n + 1);
const suffix = data.viewer_url.includes("?") ? "&" : "?";
setComputerUrl(`${data.viewer_url}${suffix}t=${Date.now()}`);
setImageFresh(action === "update" && data.updated === false);
setComputerStatus(data.crowded ? copy.computerCrowded : "");
} else {
setComputerUrl("");
setImageFresh(false);
setComputerStatus(data.error || copy.computerNotReady);
}
} catch (err) {
if (activeIdRef.current !== target) return;
setComputerReady(false);
setComputerUrl("");
setImageFresh(false);
setComputerStatus(err instanceof Error ? err.message : String(err));
} finally {
if (computerOpRef.current === op) computerOpRef.current = null;
if (activeIdRef.current === target) setComputerBusy(null);
}
}, []);
const ensureComputer = useCallback(async () => {
if (!activeIdRef.current) return;
await runComputer("start", activeIdRef.current);
}, [runComputer]);
const openAgent = useCallback(async (id: string, keepChannel = false) => {
activeIdRef.current = id;
activityGeneration.current += 1;
@ -519,6 +560,12 @@ export function App() {
setExecutionDetails([]);
setError("");
setTyping(false);
setComputerReady(false);
setComputerUrl("");
setImageFresh(false);
setComputerBusy("start");
setComputerStatus(tRef.current.computerStarting);
void runComputer("start", id);
try {
const data = await api.agent(id);
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
@ -533,47 +580,7 @@ export function App() {
setTranscript([]);
setError(err instanceof Error ? err.message : String(err));
}
}, [applyActivity, applyTranscript]);
const runComputer = useCallback(async (action: ComputerAction) => {
const copy = tRef.current;
if (computerOpRef.current) return;
if (action === "restart" && !window.confirm(copy.restartConfirm)) return;
if (action === "update" && !window.confirm(copy.updateConfirm)) return;
computerOpRef.current = action;
setComputerBusy(action);
setComputerReady(false);
if (action !== "start") setComputerUrl("");
setComputerStatus(copy[computerBusyKey(action)]);
try {
const data = await api.computer(action);
setComputerReady(Boolean(data.ready));
if (data.ready) {
setComputerGen((n) => n + 1);
const suffix = data.viewer_url.includes("?") ? "&" : "?";
setComputerUrl(`${data.viewer_url}${suffix}t=${Date.now()}`);
setImageFresh(action === "update" && data.updated === false);
setComputerStatus("");
} else {
setComputerUrl("");
setImageFresh(false);
setComputerStatus(data.error || copy.computerNotReady);
}
} catch (err) {
setComputerReady(false);
setComputerUrl("");
setImageFresh(false);
setComputerStatus(err instanceof Error ? err.message : String(err));
} finally {
computerOpRef.current = null;
setComputerBusy(null);
}
}, []);
const ensureComputer = useCallback(async () => {
if (computerOpRef.current) return;
await runComputer("start");
}, [runComputer]);
}, [applyActivity, applyTranscript, runComputer]);
useEffect(() => {
if (isPhone()) setRightOpen(false);
@ -859,13 +866,11 @@ export function App() {
const liveStep = userProgress || (workingStep === "stepSearch" ? t.stepSearch : workingStep === "stepFetch" ? t.stepFetch : workingStep ? t.stepWorking : null);
const computerLive = computerBusy
? t[computerBusyKey(computerBusy)]
: working && isComputerStep(workingStep)
? liveStep || t.computerRunning
: computerReady
? imageFresh
? t.computerUpToDate
: t.computerRunning
: t.computerOff;
: computerReady
? imageFresh
? t.computerUpToDate
: t.computerRunning
: computerStatus || t.computerOff;
const frame = computerUrl ? (
<iframe

View File

@ -105,14 +105,25 @@ export const api = {
request<{ queued?: string }>(`/api/agents/${id}/messages`, { method: "POST", body: JSON.stringify({ text }) }),
handoverDone: (id: string, taskId: string, questionId: string, action: "done" | "cancel" = "done") => request(`/api/agents/${id}/handover`, { method: "POST", body: JSON.stringify({ task_id: taskId, question_id: questionId, action }), signal: AbortSignal.timeout(8000) }),
stop: (id: string) => request(`/api/agents/${id}/stop`, { method: "POST", body: "{}" }),
computer: (action: "start" | "restart" | "update" = "start") =>
computerStatus: (id: string) =>
request<{
ready: boolean;
state?: string;
viewer_url: string;
error?: string;
revision?: string;
crowded?: boolean;
}>(`/api/agents/${encodeURIComponent(id)}/computer`),
computer: (id: string, action: "start" | "restart" | "update" = "start") =>
request<{
ready: boolean;
state?: string;
viewer_url: string;
error?: string;
updated?: boolean;
revision?: string;
}>("/api/computer", {
crowded?: boolean;
}>(`/api/agents/${encodeURIComponent(id)}/computer`, {
method: "POST",
body: JSON.stringify({ action }),
}),

View File

@ -36,10 +36,10 @@ const zhHant = {
settingsLabel: "LazyBoy 設定",
workspaceDetail: "Agent 與對話都留在這台機器。",
versionLine: "版本 {version}",
computerUpdateCopy: "更新助手共用的電腦。檔案與登入會留。",
computerUpdateCopy: "更新所有助手共用的電腦映像每個 Agent 的檔案與登入會留在自己的電腦。",
updateComputer: "更新電腦",
restartComputer: "重啟電腦",
restartComputerCopy: "電腦卡住時重啟。瀏覽器登入會保留。",
restartComputerCopy: "重啟目前這個 Agent 的電腦。瀏覽器登入會保留。",
appComputer: "LazyBoy 的電腦",
computerBusyCopy: "有 Agent 正在工作。現在操作會中斷它。",
localBuild: "本機版本",
@ -133,6 +133,7 @@ const zhHant = {
computerNotReady: "電腦未就緒",
computerNotStarted: "尚未啟動",
computerOff: "未啟動",
computerCrowded: "同時開著的電腦已達上限,已為這個 Agent 啟動,較閒的電腦先關掉。",
thinking: "思考中",
needsReply: "需要回覆",
stepComputer: "操作電腦",
@ -173,10 +174,10 @@ const zhHans: Messages = {
settingsLabel: "LazyBoy 设置",
workspaceDetail: "Agent 和对话都留在这台机器。",
versionLine: "版本 {version}",
computerUpdateCopy: "更新助手共用的电脑。文件和登录会留。",
computerUpdateCopy: "更新所有助手共用的电脑镜像每个 Agent 的文件和登录会留在自己的电脑。",
updateComputer: "更新电脑",
restartComputer: "重启电脑",
restartComputerCopy: "电脑卡住时重启。浏览器登录会保留。",
restartComputerCopy: "重启当前这个 Agent 的电脑。浏览器登录会保留。",
appComputer: "LazyBoy 的电脑",
computerBusyCopy: "有 Agent 正在工作。现在操作会中断它。",
localBuild: "本地版本",
@ -270,6 +271,7 @@ const zhHans: Messages = {
computerNotReady: "电脑未就绪",
computerNotStarted: "尚未启动",
computerOff: "未启动",
computerCrowded: "同时开着的电脑已达上限,已为这个 Agent 启动,较闲的电脑先关掉。",
thinking: "思考中",
needsReply: "需要回复",
stepComputer: "操作电脑",
@ -307,10 +309,10 @@ const en: Messages = {
settingsLabel: "LazyBoy settings",
workspaceDetail: "Agents and chats stay on this machine.",
versionLine: "Version {version}",
computerUpdateCopy: "Updates the computer your assistants share. Your files and logins stay.",
computerUpdateCopy: "Rebuilds the image shared by every assistant. Each agent's files and logins stay on its own computer.",
updateComputer: "Update LazyBoy's Computer",
restartComputer: "Restart LazyBoy's Computer",
restartComputerCopy: "Restart if the computer gets stuck. Browser logins are kept.",
restartComputerCopy: "Restart this agent's computer. Browser logins are kept.",
appComputer: "LazyBoy's Computer",
computerBusyCopy: "An agent is working. This will interrupt it.",
localBuild: "Local build",
@ -404,6 +406,7 @@ const en: Messages = {
computerNotReady: "Computer is not ready",
computerNotStarted: "Not started yet",
computerOff: "Off",
computerCrowded: "Too many computers were running, so an idle one was stopped to start this agent's.",
thinking: "Thinking",
needsReply: "Needs a reply",
stepComputer: "Using the computer",
@ -441,10 +444,10 @@ const ja: Messages = {
settingsLabel: "LazyBoy の設定",
workspaceDetail: "Agent と会話はこのマシンに残ります。",
versionLine: "バージョン {version}",
computerUpdateCopy: "アシスタントが共有するコンピュータを更新します。ファイルとログインは残ります。",
computerUpdateCopy: "全アシスタント共用のイメージを更新します。各 Agent のファイルとログインは自分のコンピュータに残ります。",
updateComputer: "コンピュータを更新",
restartComputer: "コンピュータを再起動",
restartComputerCopy: "コンピュータが止まったときに再起動します。ブラウザのログインは残ります。",
restartComputerCopy: "この Agent のコンピュータを再起動します。ブラウザのログインは残ります。",
appComputer: "LazyBoy のコンピュータ",
computerBusyCopy: "Agent が作業中です。今操作すると中断されます。",
localBuild: "ローカルビルド",
@ -538,6 +541,7 @@ const ja: Messages = {
computerNotReady: "コンピュータの準備ができていません",
computerNotStarted: "まだ起動していません",
computerOff: "停止中",
computerCrowded: "同時に起動できるコンピュータ数の上限に達したため、この Agent を起動し、使っていないコンピュータを止めました。",
thinking: "考え中",
needsReply: "返信が必要です",
stepComputer: "コンピュータを操作中",