diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..94289d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing + +## Dev Setup + +Requires [Bun](https://bun.sh/) (v1.2+) and a [Rust toolchain](https://rustup.rs/) for the broker. + +```bash +git clone https://github.com/almogdepaz/wolfpack.git +cd wolfpack +bun install +bun run scripts/gen-assets.ts # generate embedded assets (required once) +cargo build --release --manifest-path broker/Cargo.toml # build the broker +bun run src/cli/index.ts # start the server locally +``` + +For an end-to-end local install (build + service install + restart), use `scripts/deploy-local.sh`. + +## Testing + +```bash +bun test # all bun tests +bun test tests/unit/ # unit tests only +bun test tests/unit/plan-parsing.test.ts # single file +bunx playwright test # e2e (uses test-server harness) +``` + +Layout: + +- `tests/unit/` — pure-logic tests (plan parsing, ralph log parsing, escaping, validation, grid logic, broker codec, etc.) +- `tests/integration/` — API routes, broker backend, ralph loop endpoints, WS dispatch +- `tests/snapshot/` — launchd plist and systemd unit generation +- `tests/e2e/` — Playwright end-to-end (`test:e2e` / `test:e2e:headed`) + +The Rust broker has its own tests under `broker/tests/` — run with `cargo test` from `broker/`. + +## Asset Pipeline + +Frontend files live in `public/`. The server doesn't serve from disk — everything is embedded into the binary: + +1. Edit files in `public/` (HTML, TS, CSS, manifest, etc.) +2. Run `bun run scripts/gen-assets.ts` — bundles `public/app.ts` and ghostty-web, then embeds every file from `public/` into `src/public-assets.ts` (binary → base64, text → string) +3. **Do NOT edit `src/public-assets.ts` manually** — it's auto-generated + +## Building Release Binaries + +```bash +bun run scripts/build.ts +``` + +Produces `wolfpack` for linux-x64, linux-arm64, darwin-x64, darwin-arm64 plus per-platform npm package directories in `dist/`. Also stages `wolfpack-broker` per platform — in CI it expects pre-built broker binaries under `dist/broker//`; locally it falls back to a host-arch-only `cargo build --release`. + +## PR Conventions + +- Branch off `main` +- Tests must pass (`bun test`) +- Keep PRs focused — one feature or fix per PR +- Match existing style; no large unrelated refactors mixed in + +## Migrating Old Plan Files + +If you have a Ralph plan file from before the `## N. Title` header convention: + +```bash +wolfpack migrate-plan PLAN.md +``` + +This rewrites the file in place. diff --git a/README.md b/README.md index 2530bc5..13638e4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)]() [![Version](https://img.shields.io/github/v/release/almogdepaz/wolfpack?label=version)](https://github.com/almogdepaz/wolfpack/releases) -[![GitHub stars](https://img.shields.io/github/stars/almogdepaz/wolfpack?style=social)](https://github.com/almogdepaz/wolfpack/stargazers) ``` ...:. @@ -35,193 +34,106 @@ :+**++++++*++*+=-:: .. ...... .. .:..:: ``` -Mobile & desktop command center for AI coding agents. Control agent sessions (Claude, Codex, Gemini, or any custom command) across multiple machines from your phone or browser. Sessions live in a dedicated Rust PTY broker daemon, so they survive wolfpack server restarts and redeploys. Secured by [Tailscale](https://tailscale.com/) — zero-config encrypted access, no ports to open. +Drive AI coding agents (Claude, Codex, Gemini, any shell command) from your phone or browser. Sessions live in a Rust PTY broker that outlives the web server, so restarts don't kill your agents. Designed to ride on top of [Tailscale](https://tailscale.com/) — no ports to open, no DNS to wire up. -Install on your phone's home screen for a native app experience — scan the QR code after setup and tap **"Add to Home Screen"**. - -### Desktop

Desktop — terminal with collapsible sidebar

Desktop — multi-terminal grid view

- -### Mobile - -

- Mobile — session list with multi-machine support -

- Mobile — ghostty-web terminal + Mobile — session list across machines

-## Architecture - -``` -┌─────────────┐ ┌───────────┐ ┌──────────────────────────────────────────┐ -│ Phone / │ │ Tailscale │ │ Your Machine │ -│ Browser │◄──►│ (HTTPS) │◄──►│ │ -│ (PWA) │ │ mesh VPN │ │ ┌──────────┐ unix ┌──────────────┐ │ -└─────────────┘ └───────────┘ │ │ wolfpack │ socket │ wolfpack- │ │ - │ │ server │◄───────►│ broker │ │ - │ │ (Bun) │ │ (Rust, PTY) │ │ - │ │ HTTP/WS │ │ owns agents │ │ - │ └──────────┘ └──────────────┘ │ - └──────────────────────────────────────────┘ -``` - -**Components:** -- **PWA** — vanilla JS, no framework. ghostty-web (WASM) renders the terminal on both mobile and desktop. Settings + multi-machine list persist in localStorage. -- **Server** — Bun HTTP + WebSocket. Serves embedded assets, exposes `/api/*` and the `/ws/pty` binary stream. Pure broker client — owns no PTYs. -- **Broker** — `wolfpack-broker`, a Rust daemon. Owns every PTY, keeps per-session output rings, and survives wolfpack server restarts. One Unix-domain socket per host (`$XDG_RUNTIME_DIR/wolfpack-broker.sock`, fallback `~/.wolfpack/broker.sock`). Wire format documented in [docs/broker-protocol.md](docs/broker-protocol.md). -- **Ralph** — detached subprocess that iterates through a markdown plan file, invoking agents per-task. See [docs/ralph-macchio.md](docs/ralph-macchio.md). -- **Agents** — Claude, Codex, Gemini, or any shell command. Agent-agnostic by design. - -## Quick Install - -```bash -bunx wolfpack-bridge -``` - -Or with npx: - -```bash -npx wolfpack-bridge -``` - -Or via shell script (no Node/Bun required): +## Install ```bash curl -fsSL https://raw.githubusercontent.com/almogdepaz/wolfpack/main/install.sh | bash ``` -This will download the pre-built binary for your platform, run the setup wizard, and optionally install as a login service. - -Supported platforms: macOS (Apple Silicon, Intel), Linux (x64, arm64). Each platform package ships both `wolfpack` (the Bun binary) and `wolfpack-broker` (the Rust daemon). +Downloads the right pre-built binary for your platform, runs the setup wizard, and optionally installs as a login service. No runtime deps — broker is bundled. -### Prerequisites - -- **Tailscale** *(optional)* — install from [tailscale.com/download](https://tailscale.com/download), sign in, and make sure both your computer and phone are on the same tailnet. Required for remote access. - -No other runtime dependencies. The broker is bundled. - -### Session Persistence - -The `wolfpack-broker` daemon owns every PTY and runs independently of the wolfpack server. If the server crashes, gets redeployed, or restarts (e.g. `launchctl kickstart`), agent sessions keep running. When the server comes back up, it reconnects to the existing broker over the Unix socket and re-attaches to live sessions automatically. - -The broker is started by `wolfpack service install` (alongside the server) and is checked by `wolfpack doctor`. - -## Usage +
+Alternative: install via Bun / npm ```bash -wolfpack # Start the server (runs setup on first launch) -wolfpack setup # Re-run the setup wizard -wolfpack ls # List active broker sessions -wolfpack kill # Kill a session by name -wolfpack doctor # Diagnose broker socket, binaries, JWT, Tailscale -wolfpack migrate-plan FILE # Convert old-format plan headers to ## N. Title -wolfpack service install # Auto-start on login (launchd / systemd) — installs broker too -wolfpack service stop # Stop the background service -wolfpack service start # Start the background service -wolfpack service status # Check if running -wolfpack service uninstall # Remove the launch agent -wolfpack uninstall --yes # Remove everything (service, config, ~/.wolfpack, global command) +bunx wolfpack-bridge # or: npx wolfpack-bridge ``` -### Setup Wizard - -On first run, `wolfpack` walks you through: - -1. Checking prerequisites (Tailscale — optional) -2. Setting your projects directory (default: `~/Dev`) -3. Choosing a port (default: `18790`) -4. Detecting/configuring Tailscale HTTPS access -5. Optionally installing as a login service (which also installs the broker) -6. Displaying a QR code to scan with your phone -7. Printing JWT setup instructions - -## Features - -### Session Management -- Create, view, and kill agent sessions — all owned by the broker daemon -- Agent picker — Claude, Codex, Gemini, or custom commands per session (configurable in Settings → Agents) -- Session triage — running, idle, and needs-input states with color-coded indicators -- Live terminal output preview on session cards +
-### Desktop -- **Multi-terminal grid** — view multiple sessions side-by-side in a CSS grid layout. Click `+` on any sidebar card to add it to the grid, `×` to remove. Focused cell highlighted. -- **Collapsible sidebar** — pin or auto-hide. Shows all sessions across machines with status badges, output preview, and grid/kill buttons. -- **ghostty-web terminal** — full WASM terminal emulator with direct binary `/ws/pty` connection. Per-instance isolation lets each grid cell run its own emulator. -- **Keyboard shortcuts:** - - `Cmd/Ctrl + ArrowUp/Down` — cycle between sessions - - `Cmd/Ctrl + ArrowLeft/Right` — navigate grid cells - - `Cmd/Ctrl + T` — new session (project picker) - - `Cmd/Ctrl + K` — clear focused terminal +Supported: macOS (arm64/x64), Linux (x64/arm64). -### Mobile -- **ghostty-web terminal** — same WASM emulator as desktop, with the on-screen keyboard suppressed until you tap the keyboard button (prevents accidental focus steals). -- **Keyboard accessory** — quick-action bar with Enter, Esc, arrow keys, a `git` shortcut, and copy/keyboard buttons. -- **Quick commands** — user-defined command chips, configurable in Settings. -- **Touch scrolling** — momentum physics, long-press to select text and copy. -- **Haptic feedback** — vibration on key actions (toggleable). -- **PWA** — install as a standalone app on your phone's home screen. +## First Run -All settings (font size, haptics, enter-sends, snapshot TTL, etc.) persist in localStorage across sessions. +`wolfpack` walks you through: -### Multi-Machine -- One phone connects to multiple Wolfpack servers -- Sessions grouped by machine with online/offline status -- Auto-discover Tailscale peers running Wolfpack -- Cross-machine session management from a single UI +1. Install Tailscale (recommended — you almost certainly want remote access) +2. Pick a projects directory and port +3. Detect your Tailscale hostname and run `tailscale serve` for HTTPS +4. Install as a login service (optional) +5. Print a QR code -### Other -- **Notifications** — browser notifications + vibration when sessions need attention -- **Reconnect handling** — auto-recovers on connection drop with status indicator -- **Auto-resize** — terminal resizes to match your screen/grid cell +Scan the QR with your phone, tap **Add to Home Screen**, done. -### Remote Access +## What It Does -1. Install [Tailscale](https://tailscale.com/download) on both your computer and phone -2. Sign in to the same Tailscale account on both devices -3. Run `wolfpack setup` — it auto-detects your Tailscale hostname and runs `tailscale serve` to expose the port over HTTPS -4. Scan the QR code with your phone -5. Tap **"Add to Home Screen"** for the native app experience +- **Multi-machine** — one phone manages sessions on every machine on your tailnet. Online/offline status per machine, cross-machine session list. +- **Session triage** — color-coded states (running / idle / needs-input), live output preview on cards. +- **Agent-agnostic** — Claude, Codex, Gemini, or any custom command. Configure per-session in Settings → Agents. +- **Survives restarts** — the broker daemon owns every PTY. Redeploy the server, agents keep running. +- **Desktop grid** — view up to 6 sessions side-by-side. Add via `+`, remove via `×`, `Cmd+ArrowLeft/Right` to navigate. +- **Mobile-first terminal** — ghostty-web (WASM) emulator. Keyboard accessory bar (arrows, Esc, `git`, copy). On-screen keyboard suppressed until you ask for it. Long-press to select. +- **PWA** — install on home screen. Notifications + vibration when sessions need attention. Reconnects on drop. +- **Ralph loop** — autonomous task runner. Hand it a markdown plan, it iterates through tasks with an agent, committing along the way. See [docs/ralph-macchio.md](docs/ralph-macchio.md). -Tailscale's encrypted mesh network handles auth and routing — no ports to open, no DNS to configure. +## CLI -### Security +``` +wolfpack Start the server (runs setup on first launch) +wolfpack setup Re-run the setup wizard +wolfpack ls List active broker sessions +wolfpack kill Kill a session +wolfpack doctor Diagnose broker, binaries, JWT, Tailscale +wolfpack service ... install / start / stop / status / uninstall (launchd / systemd) +wolfpack uninstall --yes Remove everything +``` -**Always use the Tailscale hostname** (e.g. `https://mybox.tail1234.ts.net`) — not raw IPs. The QR code from setup already points to the correct URL. Raw IP access (LAN or Tailscale `100.x.x.x`) bypasses Tailscale's DNS-based routing and may not be protected by CORS. +## Architecture -**JWT authentication** adds a second layer of protection. Without it, anyone who can reach the server port has full access to your sessions. To enable: +``` +┌─────────────┐ ┌───────────┐ ┌──────────────────────────────────────────┐ +│ Phone / │ │ Tailscale │ │ Your Machine │ +│ Browser │◄──►│ (HTTPS) │◄──►│ │ +│ (PWA) │ │ mesh VPN │ │ ┌──────────┐ unix ┌──────────────┐ │ +└─────────────┘ └───────────┘ │ │ wolfpack │ socket │ wolfpack- │ │ + │ │ server │◄───────►│ broker │ │ + │ │ (Bun) │ │ (Rust, PTY) │ │ + │ │ HTTP/WS │ │ owns agents │ │ + │ └──────────┘ └──────────────┘ │ + └──────────────────────────────────────────┘ +``` -1. Generate a secret (minimum 32 characters): - ```bash - openssl rand -base64 48 - ``` -2. Set the environment variable before starting wolfpack: - ```bash - export WOLFPACK_JWT_SECRET="your-secret-here" - ``` - For service installs, add it to your shell profile or the service environment. +- **PWA** — vanilla JS, no framework. ghostty-web renders the terminal. +- **Server** — Bun HTTP + WebSocket. Pure broker client; owns no PTYs. +- **Broker** — `wolfpack-broker`, Rust daemon. Owns every PTY, keeps per-session output rings. One Unix-domain socket per host (`$XDG_RUNTIME_DIR/wolfpack-broker.sock`, fallback `~/.wolfpack/broker.sock`). Wire protocol in [docs/broker-protocol.md](docs/broker-protocol.md). -3. Optional configuration: - - `WOLFPACK_JWT_AUDIENCE` — expected `aud` claim - - `WOLFPACK_JWT_ISSUER` — expected `iss` claim - - `WOLFPACK_JWT_CLOCK_TOLERANCE_SEC` — clock skew tolerance (default: 30s) +## Optional: JWT Auth -Tokens use HS256 (HMAC-SHA256). The server validates but does not issue tokens — generate them with any JWT library using the same secret. +Tailscale already gates who can reach the server. If you want an extra auth layer on top — useful if you share your tailnet with others, or for defense-in-depth — set a JWT secret: -**Without `WOLFPACK_JWT_SECRET` set, authentication is disabled.** This is fine for localhost-only usage but strongly recommended when the server is reachable over a network. +```bash +export WOLFPACK_JWT_SECRET="$(openssl rand -base64 48)" +``` -## Ralph Loop +Tokens are HS256; the server validates, it does not issue — sign them with any JWT library using the same secret. -Autonomous task runner. Write a markdown plan file, pick an agent, set iterations, and let it rip. Ralph reads the plan, extracts the first incomplete task, hands it to the agent, marks it done, and moves on — implementing, testing, and committing along the way. See [full documentation](docs/ralph-macchio.md). +Optional: `WOLFPACK_JWT_AUDIENCE`, `WOLFPACK_JWT_ISSUER`, `WOLFPACK_JWT_CLOCK_TOLERANCE_SEC` (default 30s). ## Config -Stored in `~/.wolfpack/config.json` (mode 0600): +`~/.wolfpack/config.json` (mode 0600): ```json { @@ -231,71 +143,13 @@ Stored in `~/.wolfpack/config.json` (mode 0600): } ``` -Agent list and per-server settings stored in `~/.wolfpack/bridge-settings.json`. - -The broker socket lives at `$XDG_RUNTIME_DIR/wolfpack-broker.sock` (or `~/.wolfpack/broker.sock`) and is owned by the user (filesystem permissions are the auth boundary). +Per-server agent settings in `~/.wolfpack/bridge-settings.json`. The broker socket's filesystem permissions are the auth boundary. ## Contributing -### Dev Setup - -Requires [Bun](https://bun.sh/) (v1.2+) and a [Rust toolchain](https://rustup.rs/) (for building the broker). - -```bash -git clone https://github.com/almogdepaz/wolfpack.git -cd wolfpack -bun install -bun run scripts/gen-assets.ts # generate embedded assets (required once) -cargo build --release --manifest-path broker/Cargo.toml # build the broker -bun run src/cli/index.ts # start the server locally -``` - -For an end-to-end local install (build + service install + restart), use `scripts/deploy-local.sh`. - -### Testing - -```bash -bun test # all bun tests -bun test tests/unit/ # unit tests only -bun test tests/unit/plan-parsing.test.ts # single file -bunx playwright test # e2e (uses test-server harness) -``` - -Test layout: -- `tests/unit/` — pure-logic tests (plan parsing, ralph log parsing, escaping, validation, grid logic, broker codec, etc.) -- `tests/integration/` — API routes, broker backend, ralph loop endpoints, WS dispatch -- `tests/snapshot/` — launchd plist and systemd unit generation -- `tests/e2e/` — Playwright end-to-end (`test:e2e` / `test:e2e:headed` scripts) - -The Rust broker has its own tests under `broker/tests/` (`cargo test` from `broker/`). - -### Asset Pipeline - -Frontend files live in `public/`. The server doesn't serve from disk — everything is embedded: - -1. Edit files in `public/` (HTML, TS, CSS, manifest, etc.) -2. Run `bun run scripts/gen-assets.ts` — bundles `public/app.ts` and ghostty-web, then embeds every file from `public/` into `src/public-assets.ts` (binary→base64, text→string) -3. **Do NOT edit `src/public-assets.ts` manually** — it's auto-generated - -### Building Binaries - -```bash -bun run scripts/build.ts # assets + broker + 4 platform binaries + npm pkg dirs in dist/ -``` - -Compiles `wolfpack` for: linux-x64, linux-arm64, darwin-x64, darwin-arm64. The script also stages `wolfpack-broker` per platform — in CI it expects pre-built broker binaries under `dist/broker//`; locally it falls back to a host-arch-only `cargo build --release`. - -### PR Conventions - -- Branch off `main` -- Tests must pass (`bun test`) -- Keep PRs focused — one feature or fix per PR - -## Community & Support +See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, the asset pipeline, and PR conventions. -- 💬 [Open a Discussion](https://github.com/almogdepaz/wolfpack/discussions) — questions, ideas, show & tell -- 🐛 [File an Issue](https://github.com/almogdepaz/wolfpack/issues) — bugs and feature requests -- ⭐ **Star the repo** if Wolfpack saves you time — it helps others find it +Bugs and feature requests: [GitHub Issues](https://github.com/almogdepaz/wolfpack/issues). Questions and ideas: [Discussions](https://github.com/almogdepaz/wolfpack/discussions). ## License diff --git a/src/server/routes.ts b/src/server/routes.ts index 12f30ae..cc84642 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -8,8 +8,6 @@ import { readdirSync, mkdirSync, statSync, - lstatSync, - realpathSync, existsSync, unlinkSync, openSync, @@ -39,7 +37,8 @@ import { getVapidPublicKey, addSubscription, removeSubscription, sendPush, valid import pkg from "../../package.json"; const log = createLogger("routes"); -import { DEV_DIR, isUnderDevDir } from "./dev-dir.js"; +import { DEV_DIR } from "./dev-dir.js"; +import { validateProjectDir as validateProjectDirPure } from "./validate-project-dir.js"; import { RALPH_AGENTS } from "./shell.js"; import { getBackend, getRouter } from "./backend.js"; import { @@ -128,23 +127,14 @@ function validateProject(res: ServerResponse, project: string | null | undefined return true; } -/** Validate project directory exists, is not a symlink, and resolves under DEV_DIR. */ +/** Validate project directory exists, is not a symlink, and resolves under DEV_DIR. + * Thin HTTP wrapper around the pure `validateProjectDirPure` so the security-sensitive + * containment logic lives in one tested place. */ function validateProjectDir(res: ServerResponse, projectDir: string): boolean { - try { - if (lstatSync(projectDir).isSymbolicLink() || !statSync(projectDir).isDirectory()) { - json(res, { error: "not a directory" }, 400); - return false; - } - // defense-in-depth: verify realpath is contained under DEV_DIR - if (!isUnderDevDir(realpathSync(projectDir))) { - json(res, { error: "not a directory" }, 400); - return false; - } - } catch { /* expected: stat fails when project dir doesn't exist */ - json(res, { error: "project directory not found" }, 404); - return false; - } - return true; + const result = validateProjectDirPure(projectDir); + if (result.ok) return true; + json(res, { error: result.error }, result.code === "not_found" ? 404 : 400); + return false; } import { diff --git a/src/server/validate-project-dir.ts b/src/server/validate-project-dir.ts new file mode 100644 index 0000000..6b8902f --- /dev/null +++ b/src/server/validate-project-dir.ts @@ -0,0 +1,37 @@ +/** + * Project-directory validation — pure module (no HTTP side-effects). + * + * Extracted from routes.ts so regression tests can exercise the real + * implementation instead of a copy. The routes.ts call sites wrap this + * with response-emitting helpers. + */ +import { lstatSync, statSync, realpathSync } from "node:fs"; +import { isUnderDevDir } from "./dev-dir.js"; + +export type ValidateProjectDirResult = + | { ok: true; projectDir: string } + | { ok: false; code: "not_dir" | "not_found"; error: string }; + +/** + * Validate that `projectDir`: + * 1. exists, + * 2. is not a symlink (lstat), + * 3. is a directory, + * 4. and its realpath resolves under DEV_DIR (defense-in-depth). + * + * Returns a discriminated result rather than mutating an HTTP response. + * Callers translate `code` to a status code (`not_dir` → 400, `not_found` → 404). + */ +export function validateProjectDir(projectDir: string): ValidateProjectDirResult { + try { + if (lstatSync(projectDir).isSymbolicLink() || !statSync(projectDir).isDirectory()) { + return { ok: false, code: "not_dir", error: "not a directory" }; + } + if (!isUnderDevDir(realpathSync(projectDir))) { + return { ok: false, code: "not_dir", error: "not a directory" }; + } + } catch { + return { ok: false, code: "not_found", error: "project directory not found" }; + } + return { ok: true, projectDir }; +} diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 3085c59..17ffdae 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -88,6 +88,49 @@ const QUIESCE_WINDOW_MS = 100; const QUIESCE_BYTE_THRESHOLD = 1024; const QUIESCE_TIMEOUT_MS = 800; const QUIESCE_MIN_WAIT_MS = 80; +// Hard cap for sessions that never quiesce (animated TUIs: spinners, +// progress bars, htop, watch). Without this, an always-busy session waits +// the full QUIESCE_TIMEOUT_MS (800ms) before snapshotting, producing a +// visibly garbled mid-redraw prefill that the live stream then has to +// overwrite. Capping at 200ms cuts the worst-case prefill-garble window +// by 4x for animated sessions while leaving the common quiet-path +// behavior unchanged: real apps quiet inside QUIESCE_WINDOW_MS well +// before this cap is reached. Issue #129. +const QUIESCE_ANIMATED_CAP_MS = 200; + +/** + * Pure decision for the quiescence loop. Returns one of: + * - "continue" — keep observing + * - "quiet" — recent byte rate below threshold, safe to snapshot + * - "animated_cap" — byte rate stayed high through the animated-cap + * window; we snapshot a (potentially mid-redraw) frame rather than + * wait the full timeout + * - "timeout" — absolute settle timeout reached + * + * Exported for unit tests. The loop's setTimeout(16) wait and the resize + * side-effects live at the call site. + */ +export function quiescenceDecision(args: { + samples: Array<{ t: number; bytes: number }>; + now: number; + lastResizeAt: number; + settleStart: number; +}): "continue" | "quiet" | "animated_cap" | "timeout" { + const { samples, now, lastResizeAt, settleStart } = args; + const elapsedTotal = now - settleStart; + if (elapsedTotal >= QUIESCE_TIMEOUT_MS) return "timeout"; + const elapsedSinceResize = now - lastResizeAt; + if (elapsedSinceResize < QUIESCE_MIN_WAIT_MS) return "continue"; + const cutoff = now - QUIESCE_WINDOW_MS; + let recentBytes = 0; + for (const s of samples) if (s.t >= cutoff) recentBytes += s.bytes; + if (recentBytes < QUIESCE_BYTE_THRESHOLD) return "quiet"; + // Byte rate stayed high through the entire animated-cap window measured + // from settle start. This is the always-busy TUI signature — don't wait + // out the full 800ms timeout. + if (elapsedTotal >= QUIESCE_ANIMATED_CAP_MS) return "animated_cap"; + return "continue"; +} // Adaptive coalescing of broker output frames before forwarding to viewer. // See call site for full reasoning. const COALESCE_FLUSH_MS = 16; @@ -507,14 +550,17 @@ function setupNewPtyEntry( await backend.resize(session, appliedSize.cols, appliedSize.rows); lastResizeAt = Date.now(); } - const elapsedTotal = Date.now() - settleStart; - if (elapsedTotal >= QUIESCE_TIMEOUT_MS) break; - const elapsedSinceResize = Date.now() - lastResizeAt; - if (elapsedSinceResize >= QUIESCE_MIN_WAIT_MS) { - const cutoff = Date.now() - QUIESCE_WINDOW_MS; - while (samples.length > 0 && samples[0].t < cutoff) samples.shift(); - const recentBytes = samples.reduce((s, x) => s + x.bytes, 0); - if (recentBytes < QUIESCE_BYTE_THRESHOLD) break; + const now = Date.now(); + // Trim samples outside the rolling window so the array stays + // bounded across long never-quiet sessions. + const cutoff = now - QUIESCE_WINDOW_MS; + while (samples.length > 0 && samples[0].t < cutoff) samples.shift(); + const decision = quiescenceDecision({ samples, now, lastResizeAt, settleStart }); + if (decision !== "continue") { + if (decision === "animated_cap" || decision === "timeout") { + log.debug("quiescence loop exited without quiet", { session, reason: decision, elapsedMs: now - settleStart }); + } + break; } await new Promise(resolve => setTimeout(resolve, 16)); } diff --git a/tests/unit/audit-regressions.test.ts b/tests/unit/audit-regressions.test.ts index 7488561..f7cf909 100644 --- a/tests/unit/audit-regressions.test.ts +++ b/tests/unit/audit-regressions.test.ts @@ -39,56 +39,47 @@ describe("isUnderDevDir — path containment boundary", () => { }); // ── 1b. validateProjectDir realpath containment ── +// Imports the real `validateProjectDir` from src/ so this test cannot drift +// from production. `isUnderDevDir` resolves DEV_DIR from process.env.WOLFPACK_DEV_DIR +// at call time (set at top of file to /Users/home/Dev/). -import { mkdtempSync, mkdirSync, symlinkSync, rmSync, realpathSync, lstatSync, statSync } from "node:fs"; +import { mkdtempSync, mkdirSync, symlinkSync, rmSync, realpathSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; - -/** - * Mirrors the core logic of routes.ts validateProjectDir(): - * rejects symlinks, rejects dirs whose realpath escapes DEV_DIR. - */ -function validateProjectDir(projectDir: string, devDir: string): "ok" | "not_dir" | "not_found" { - try { - if (lstatSync(projectDir).isSymbolicLink() || !statSync(projectDir).isDirectory()) return "not_dir"; - if (!isUnderDevDir(realpathSync(projectDir))) return "not_dir"; - } catch { - return "not_found"; - } - return "ok"; -} +const { validateProjectDir } = await import("../../src/server/validate-project-dir.js"); describe("validateProjectDir — realpath containment", () => { - let testDevDir: string; - let outsideDir: string; - - test("accepts real directory under DEV_DIR", () => { - testDevDir = mkdtempSync(join(tmpdir(), "wolfpack-devdir-")); + test("rejects directory whose realpath escapes DEV_DIR", () => { + const testDevDir = mkdtempSync(join(tmpdir(), "wolfpack-devdir-")); const project = join(testDevDir, "legit-project"); mkdirSync(project); - // isUnderDevDir checks against process.env.WOLFPACK_DEV_DIR which is /Users/home/Dev/ - // so we test the realpathSync + isUnderDevDir logic directly + // testDevDir is under /tmp (or macOS equivalent), not under DEV_DIR (/Users/home/Dev/), + // so containment must fail. const real = realpathSync(project); - // the project's realpath is under testDevDir (not under DEV_DIR), so isUnderDevDir returns false - // This verifies the containment check works expect(isUnderDevDir(real)).toBe(false); - expect(validateProjectDir(project, testDevDir)).toBe("not_dir"); + const result = validateProjectDir(project); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("not_dir"); rmSync(testDevDir, { recursive: true, force: true }); }); - test("rejects symlink pointing outside DEV_DIR", () => { - testDevDir = mkdtempSync(join(tmpdir(), "wolfpack-devdir-")); - outsideDir = mkdtempSync(join(tmpdir(), "wolfpack-outside-")); + test("rejects symlink even when target would otherwise be valid", () => { + const testDevDir = mkdtempSync(join(tmpdir(), "wolfpack-devdir-")); + const outsideDir = mkdtempSync(join(tmpdir(), "wolfpack-outside-")); const symlink = join(testDevDir, "sneaky-link"); symlinkSync(outsideDir, symlink); - // lstatSync catches the symlink before realpath even runs - expect(validateProjectDir(symlink, testDevDir)).toBe("not_dir"); + // lstat catches the symlink before realpath even runs. + const result = validateProjectDir(symlink); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("not_dir"); rmSync(testDevDir, { recursive: true, force: true }); rmSync(outsideDir, { recursive: true, force: true }); }); test("rejects nonexistent directory", () => { - expect(validateProjectDir("/nonexistent/path/xyz", "/tmp")).toBe("not_found"); + const result = validateProjectDir("/nonexistent/path/xyz"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("not_found"); }); }); diff --git a/tests/unit/quiescence-decision.test.ts b/tests/unit/quiescence-decision.test.ts new file mode 100644 index 0000000..06a075d --- /dev/null +++ b/tests/unit/quiescence-decision.test.ts @@ -0,0 +1,145 @@ +/** + * Regression coverage for issue #129: animated TUIs (spinners, htop, + * `watch`, claude's pulse spinner) never let the broker byte rate drop + * below the quiescence threshold, so the old loop always waited the full + * QUIESCE_TIMEOUT_MS (800ms) before snapshotting — producing a mid-redraw + * prefill that the live stream then had to overwrite. + * + * The new QUIESCE_ANIMATED_CAP_MS short-circuits that wait at 200ms when + * byte rate stays high through the entire settle window. + */ +import { describe, expect, test } from "bun:test"; +import { quiescenceDecision } from "../../src/server/websocket.ts"; + +type Sample = { t: number; bytes: number }; + +/** Build a sample array spanning [from, to] with `bytesPerMs` bytes/ms. */ +function busySamples(from: number, to: number, bytesPerMs = 20): Sample[] { + const out: Sample[] = []; + for (let t = from; t <= to; t += 10) out.push({ t, bytes: bytesPerMs * 10 }); + return out; +} + +describe("quiescenceDecision — pure quiescence loop logic", () => { + test("returns 'continue' before MIN_WAIT_MS even when bytes are quiet", () => { + expect( + quiescenceDecision({ + samples: [], + now: 50, // < MIN_WAIT 80 + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("continue"); + }); + + test("returns 'quiet' after MIN_WAIT_MS when recent bytes are below threshold", () => { + expect( + quiescenceDecision({ + samples: [{ t: 50, bytes: 100 }], // 100 < 1024 + now: 100, + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("quiet"); + }); + + test("returns 'continue' between MIN_WAIT_MS and ANIMATED_CAP_MS while busy", () => { + expect( + quiescenceDecision({ + // 150ms × 20 b/ms = 3000 bytes per window — well above 1024 + samples: busySamples(50, 150), + now: 150, + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("continue"); + }); + + test("returns 'animated_cap' at ANIMATED_CAP_MS when byte rate stays high", () => { + expect( + quiescenceDecision({ + samples: busySamples(100, 200), + now: 200, + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("animated_cap"); + }); + + test("returns 'timeout' at QUIESCE_TIMEOUT_MS regardless of byte rate", () => { + expect( + quiescenceDecision({ + samples: busySamples(700, 800), + now: 800, + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("timeout"); + }); + + test("MIN_WAIT_MS is measured from the most recent resize, not settleStart", () => { + // 250ms total elapsed, but resize just happened at t=200 — MIN_WAIT not satisfied + expect( + quiescenceDecision({ + samples: [], + now: 250, + lastResizeAt: 200, + settleStart: 0, + }), + ).toBe("continue"); + }); + + test("a resize mid-window does not prevent animated_cap once MIN_WAIT clears", () => { + // settle started at 0, resize at 100, now at 200 → elapsedSinceResize=100>=80 + // bytes high through the post-resize window + expect( + quiescenceDecision({ + samples: busySamples(100, 200), + now: 200, + lastResizeAt: 100, + settleStart: 0, + }), + ).toBe("animated_cap"); + }); + + test("non-animated session quietens before animated_cap fires", () => { + // bytes only appear in the first 50ms (initial redraw burst), then nothing + const samples: Sample[] = [ + { t: 10, bytes: 500 }, + { t: 30, bytes: 500 }, + { t: 50, bytes: 500 }, + ]; + // By t=200 these are all outside the 100ms window → recentBytes=0 → quiet + expect( + quiescenceDecision({ + samples, + now: 200, + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("quiet"); + }); + + test("quiet wins over animated_cap when both could fire", () => { + // exactly at 200ms, but recent window happens to be empty + expect( + quiescenceDecision({ + samples: [{ t: 50, bytes: 9999 }], // far outside window + now: 200, + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("quiet"); + }); + + test("timeout wins over animated_cap when both could fire", () => { + expect( + quiescenceDecision({ + samples: busySamples(750, 800), + now: 800, + lastResizeAt: 0, + settleStart: 0, + }), + ).toBe("timeout"); + }); +});