diff --git a/Makefile b/Makefile index 702be62..f956f8a 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,8 @@ # make stop .DEFAULT_GOAL := help +.NOTPARALLEL: build start restart +export CARGO_BUILD_JOBS ?= 2 ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) RUNDIR := $(ROOT)/.run @@ -108,7 +110,7 @@ wait_listen() { name="$$3" log="$$4" i=0 - while [ $$i -lt 50 ]; do + while [ $$i -lt 150 ]; do if lsof -nP -iTCP:"$$port" -sTCP:LISTEN >/dev/null 2>&1; then return 0 fi @@ -172,12 +174,13 @@ build-bin: @cargo build -p lazyboy build-ui: - @if [ ! -d "$(WEBDIR)/node_modules" ]; then \ - echo "npm install…"; \ - (cd "$(WEBDIR)" && npm install) || exit 1; \ + @if [ ! -f "$(WEBDIR)/node_modules/.lazyboy-lock" ] || ! cmp -s "$(WEBDIR)/package-lock.json" "$(WEBDIR)/node_modules/.lazyboy-lock"; then \ + echo "installing locked web dependencies…"; \ + (cd "$(WEBDIR)" && npm ci) || exit 1; \ + cp "$(WEBDIR)/package-lock.json" "$(WEBDIR)/node_modules/.lazyboy-lock"; \ fi @echo "building web UI…" - @(cd "$(WEBDIR)" && npm run build) || exit 1 + @(cd "$(WEBDIR)" && NODE_OPTIONS="$${NODE_OPTIONS:---max-old-space-size=1024}" npm run build) || exit 1 box: @echo "building computer image lazyboy-box:local…" @@ -237,7 +240,7 @@ start-web: echo "npm install…"; \ (cd "$(WEBDIR)" && npm install) || exit 1; \ fi; \ - LAZYBOY_WEB_PORT="$(PORT_API)" nohup npm --prefix "$(WEBDIR)" run dev -- --host "$(HOST)" --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 < /dev/null & echo $$! > "$(PID_WEB)"; \ + NODE_OPTIONS="$${NODE_OPTIONS:---max-old-space-size=1024}" LAZYBOY_WEB_PORT="$(PORT_API)" nohup npm --prefix "$(WEBDIR)" run dev -- --host "$(HOST)" --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 < /dev/null & 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)" diff --git a/README.md b/README.md index 2ca972c..3765e7e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ A local, general-purpose **GrokBot-like CLI agent**. It observes the environment For action tasks the agent briefly explains its approach, then starts. Multi-stage tasks get a short live plan. You can add instructions while it works, answer a question, or interrupt and resume. Simple questions remain simple answers. +## Remote computer controls + +右側電腦採用 Grok 風格的螢幕卡片,按「放大」可開啟全頁操作。桌面版不顯示手機控制列,直接使用滑鼠鍵盤;Ctrl+Alt++/− 可縮放,Ctrl+Alt+0 重設,Ctrl+Alt+B 開啟文字剪貼簿面板。 + +手機沿用 LazyBoy 的獨立觸控板:滑動移動游標、輕點左鍵、雙指捲動/輕點右鍵,也可按左右鍵、捲動與「拖曳」(再按一次放開)。可切換直接點選模式。直向控制區在下方,橫向在左側;鍵盤、常用按鍵、1–4 倍縮放、移動畫面與雙向文字剪貼簿都在手機控制區。若區網 HTTP 或瀏覽器限制剪貼簿權限,可在面板長按貼上/複製。 + ## Start ```bash @@ -138,3 +144,9 @@ python3 tests/live_team.py # opt-in team workflow, root task ceiling 16 ``` Design and Codex references: [CLI flow](docs/CLI-FLOW.md). Product scope: [PRODUCT](docs/PRODUCT.md). Acceptance scenarios: [ACCEPTANCE](docs/ACCEPTANCE.md). Older scenario playbooks remain optional examples, not hardcoded workflows: [scenarios](docs/scenarios/README.md). + +### Memory and long-running UI + +`make start` rebuilds both services and reapplies computer limits to existing containers. Each computer is limited to 1536 MiB RAM (no additional swap), 2 CPUs and 512 processes; new containers use 256 MiB shared memory. The default maximum is three running computers, including computers recovered from previous server runs. Idle computers are stopped when a slot is needed; their workspace and browser profile volumes are retained. Busy computers are not evicted. `LAZYBOY_COMPUTER_MAX` overrides the running count. + +Chat history scrolls continuously and only mounts nearby messages. Desktop chrome is hidden by CSS without a DOM mutation feedback loop. Regression checks: `node tests/novnc_memory_ui.mjs` and `node tests/history_memory_ui.mjs` (with the frontend running). diff --git a/crates/lazyboy-core/src/box_runtime.rs b/crates/lazyboy-core/src/box_runtime.rs index f3129b5..b6e1f98 100644 --- a/crates/lazyboy-core/src/box_runtime.rs +++ b/crates/lazyboy-core/src/box_runtime.rs @@ -27,6 +27,9 @@ const BROWSER_PROFILE: &str = "/home/box/chrome-profile"; const JOB_OUTPUT_HEAD_CHARS: usize = 16_000; const JOB_OUTPUT_TAIL_CHARS: usize = 32_000; const DEFAULT_MAX_RUNNING: usize = 3; +const COMPUTER_MEMORY: &str = "1536m"; +const COMPUTER_CPUS: &str = "2"; +const COMPUTER_PIDS: &str = "512"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct BoxSeat { @@ -285,6 +288,9 @@ impl BoxHub { docker_info().await?; let _provision = ProvisionLock::acquire().await?; ensure_image().await?; + let pool = BoxPool::global(); + pool.reconcile().await?; + pool.reclaim(self.seat_id()).await?; let port = ensure_container(&self.seat).await?; wait_desktop(&self.seat.container).await?; self.inner.lock().await.host_port = Some(port); @@ -313,7 +319,11 @@ impl BoxHub { let result = async { docker_info().await?; let _provision = ProvisionLock::acquire().await?; + let pool = BoxPool::global(); + pool.reconcile().await?; + pool.reclaim(self.seat_id()).await?; if docker(["container", "inspect", self.container()]).await.is_ok() { + limit_container(self.container()).await?; docker(["restart", "-t", "20", self.container()]).await?; } else { ensure_image().await?; @@ -342,6 +352,9 @@ impl BoxHub { let result = async { docker_info().await?; let _provision = ProvisionLock::acquire().await?; + let pool = BoxPool::global(); + pool.reconcile().await?; + pool.reclaim(self.seat_id()).await?; let before = local_image_id().await; build_image().await?; let after = local_image_id().await; @@ -963,7 +976,17 @@ async fn local_image_id() -> Option { .filter(|s| !s.is_empty()) } +// Apply to existing computers too, without recreating them or discarding data. +async fn limit_container(container: &str) -> Result<()> { + docker(["update", "--memory", COMPUTER_MEMORY, "--memory-swap", COMPUTER_MEMORY, + "--cpus", COMPUTER_CPUS, "--pids-limit", COMPUTER_PIDS, container]).await?; + Ok(()) +} + async fn ensure_container(seat: &BoxSeat) -> Result { + if docker(["container", "inspect", &seat.container]).await.is_ok() { + limit_container(&seat.container).await?; + } if docker_running(&seat.container).await? && container_image_id(&seat.container).await == local_image_id().await { @@ -994,7 +1017,11 @@ async fn ensure_container(seat: &BoxSeat) -> Result { "create", "--name", &seat.container, - "--shm-size=2g", + "--memory", COMPUTER_MEMORY, + "--memory-swap", COMPUTER_MEMORY, + "--cpus", COMPUTER_CPUS, + "--pids-limit", COMPUTER_PIDS, + "--shm-size=256m", "-p", &publish, "--stop-timeout=20", @@ -1133,12 +1160,14 @@ fn max_running() -> usize { } pub struct BoxPool { + provisioning: Mutex<()>, hubs: std::sync::Mutex>>, } impl BoxPool { pub fn new() -> Arc { Arc::new(Self { + provisioning: Mutex::new(()), hubs: std::sync::Mutex::new(HashMap::new()), }) } @@ -1163,8 +1192,30 @@ impl BoxPool { self.hub(seat_id).inspect().await } + /// Recover computers left by a previous server, so limits survive make start. + pub async fn reconcile(&self) -> Result<()> { + let names = docker(["ps", "-a", "--format", "{{.Names}}"]).await?; + for name in names.stdout.lines().rev() { + let id = if name == SESSION_CONTAINER { SESSION_SEAT } + else if let Some(id) = name.strip_prefix("lazyboy-box-") { id } + else { continue }; + self.hub(id); + limit_container(name).await?; + } + Ok(()) + } + + pub async fn recover(&self) -> Result<()> { + self.reconcile().await?; + self.reclaim("").await?; + Ok(()) + } + pub async fn start(&self, seat_id: &str) -> Result { - let crowded = self.reclaim(seat_id).await; + // Serialize admission and launch; concurrent tabs cannot exceed the cap. + let _guard = self.provisioning.lock().await; + self.reconcile().await?; + let crowded = self.reclaim(seat_id).await?; let mut payload = self.hub(seat_id).ensure_ready().await?; if crowded { payload["crowded"] = json!(true); @@ -1193,8 +1244,9 @@ impl BoxPool { Ok(()) } - async fn reclaim(&self, keep: &str) -> bool { + async fn reclaim(&self, keep: &str) -> Result { let max = max_running(); + let reserve_slot = !keep.is_empty(); let keep = BoxSeat::for_id(keep).id; loop { let hubs: Vec> = self.hubs.lock().unwrap().values().cloned().collect(); @@ -1204,8 +1256,9 @@ impl BoxPool { running.push((hub.last_used().await, hub)); } } - if running.len() < max { - return false; + let already_running = running.iter().any(|(_, hub)| hub.seat_id() == keep); + if running.len() < max || ((!reserve_slot || already_running) && running.len() <= max) { + return Ok(false); } running.sort_by_key(|(used, _)| *used); let victim = running.into_iter().find(|(_, hub)| { @@ -1214,10 +1267,10 @@ impl BoxPool { match victim { Some((_, hub)) => { if hub.stop_keep_volumes().await.is_err() { - return true; + return Err(anyhow!("Could not suspend an idle computer to free memory")); } } - None => return true, + None => return Err(anyhow!("All computer slots are busy; stop an idle computer before starting another")), } } } diff --git a/crates/lazyboy-core/src/novnc_skin.rs b/crates/lazyboy-core/src/novnc_skin.rs index 949a425..74e47ad 100644 --- a/crates/lazyboy-core/src/novnc_skin.rs +++ b/crates/lazyboy-core/src/novnc_skin.rs @@ -34,41 +34,62 @@ html, body { height: 0 !important; transform: none !important; } -#noVNC_container, -#noVNC_screen, -#noVNC_container > div { - position: absolute !important; - inset: 0 !important; - width: 100% !important; - height: 100% !important; - margin: 0 !important; - padding: 0 !important; - border: 0 !important; - border-radius: 0 !important; - background: #111 !important; - overflow: hidden !important; - display: flex !important; -} -#noVNC_container canvas, -#noVNC_screen canvas { - flex: 1 1 auto !important; - width: 100% !important; - height: 100% !important; - max-width: none !important; - max-height: none !important; - margin: 0 !important; - padding: 0 !important; - border: 0 !important; - border-radius: 0 !important; - object-fit: fill !important; -} +#noVNC_container, #noVNC_container > div, #noVNC_container canvas { border-radius:0!important; clip-path:none!important; } +#noVNC_container { position:absolute!important; inset:0!important; width:100%!important; height:100%!important; overflow:auto!important; background:#111!important; } +#noVNC_screen { width:100%!important; height:100%!important; } +#noVNC_container canvas { flex:none!important; margin:auto!important; touch-action:none; } +#lazyboy-tools { position:absolute;inset:0 0 auto;z-index:100;height:48px;display:flex;align-items:center;gap:4px;padding:0 8px;background:#181818;border-bottom:1px solid #ffffff1f;box-sizing:border-box;overflow-x:auto;font:12px system-ui;color:#eee; } +#lazyboy-tools button,#lazyboy-clipboard button { flex-shrink:0;min-height:36px;padding:6px 10px;border:1px solid #ffffff24;border-radius:7px;background:#252525;color:#eee;cursor:pointer;font:inherit;touch-action:manipulation; } +#lazyboy-tools button[aria-pressed="true"] { background:#395b50; } +#lazyboy-clipboard { position:absolute;z-index:101;inset:52px 8px auto;padding:12px;background:#202020;border:1px solid #ffffff26;border-radius:10px;color:#eee;font:13px system-ui;box-shadow:0 12px 32px #0008; } +#lazyboy-clipboard[hidden] { display:none; } +#lazyboy-clipboard textarea { box-sizing:border-box;width:100%;height:110px;margin:8px 0;padding:10px;background:#111;color:#fff;border:1px solid #555;border-radius:6px;font:16px system-ui;user-select:text; } +#lazyboy-clipboard p { margin:8px 0 0;line-height:1.5; } +#lazyboy-pan { position:absolute;inset:0;z-index:99;touch-action:none;cursor:grab; } +#lazyboy-pan[hidden] { display:none; } +#lazyboy-tools { display:none; } +html[data-mobile="true"] body { display:grid;grid-template:"screen" minmax(0,1fr) "controls" auto / minmax(0,1fr); } +html[data-mobile="true"] #noVNC_container { position:relative!important;grid-area:screen;min-width:0;min-height:0; } +html[data-mobile="true"] #lazyboy-tools { position:relative;inset:auto;grid-area:controls;display:flex;flex-direction:column;align-items:stretch;height:auto;max-height:50dvh;padding:8px 8px max(8px,env(safe-area-inset-bottom));border-top:1px solid #ffffff1f;border-bottom:0;overflow:auto;gap:6px; } +#lazyboy-tools .buttons { display:flex;gap:5px;overflow-x:auto;flex-shrink:0; } +#lazyboy-tools button { min-height:44px;min-width:44px; } +#lazyboy-tools .secondary button { font-size:12px; } +#lazyboy-trackpad { height:clamp(70px,14dvh,120px);flex-shrink:0;display:grid;place-items:center;border:1px solid #ffffff30;border-radius:10px;background:#ffffff08;color:#aaa;touch-action:none;user-select:none;font-size:12px; } +#lazyboy-trackpad[hidden] { display:none; } +#lazyboy-pointer-help { margin:0;color:#999;font-size:11px;line-height:1.4; } +#lazyboy-cursor { display:none;position:fixed;z-index:98;width:16px;height:16px;border:2px solid white;border-radius:50%;box-shadow:0 0 0 1px #000;transform:translate(-50%,-50%);pointer-events:none; } +#lazyboy-cursor::after { content:"";position:absolute;inset:4px;background:white;border-radius:50%; } +html[data-mobile="true"][data-landscape="true"] body { grid-template:"controls screen" minmax(0,1fr) / 190px minmax(0,1fr); } +html[data-mobile="true"][data-landscape="true"] #lazyboy-tools { max-height:100%;border-top:0;border-right:1px solid #ffffff1f; } +html[data-landscape="true"] #lazyboy-tools .buttons { flex-wrap:wrap;overflow:visible; } +html[data-landscape="true"] #lazyboy-trackpad { height:80px; } +html[data-theme="light"] #lazyboy-trackpad { background:#eeeef0;color:#62626a;border-color:#d0d0d6; } +html[data-theme="light"] #lazyboy-pointer-help { color:#62626a; } +html[data-theme="light"] #lazyboy-tools { background:#f7f7f8;color:#18181b;border-color:#d8d8dc; } +html[data-theme="light"] #lazyboy-tools button, +html[data-theme="light"] #lazyboy-clipboard button { background:#fff;color:#18181b;border-color:#d8d8dc; } +html[data-theme="light"] #lazyboy-tools button[aria-pressed="true"] { background:#dceee6; } +html[data-theme="light"] #lazyboy-clipboard { background:#fff;color:#18181b;border-color:#d8d8dc;box-shadow:0 12px 32px #0002; } +html[data-theme="light"] #lazyboy-clipboard textarea { background:#f5f5f6;color:#18181b;border-color:#c9c9ce; }