From 13f47d9d7c4e7d0a82392c155aecfa8c98edb69a Mon Sep 17 00:00:00 2001 From: almogdepaz Date: Wed, 13 May 2026 02:28:59 +0300 Subject: [PATCH 1/7] refactor: extract validateProjectDir to pure module (closes #133) The HTTP-flavored validateProjectDir in routes.ts had its core logic duplicated as an inlined copy inside tests/unit/audit-regressions.test.ts so the test could exercise it without an HTTP response object. The comment in the test acknowledged the drift risk on a security-sensitive path-containment boundary. Extract the pure validation to src/server/validate-project-dir.ts returning a discriminated result. routes.ts wraps it with the existing HTTP response surface; the regression test now imports the real function instead of a copy. --- src/server/routes.ts | 28 +++++---------- src/server/validate-project-dir.ts | 37 +++++++++++++++++++ tests/unit/audit-regressions.test.ts | 53 ++++++++++++---------------- 3 files changed, 68 insertions(+), 50 deletions(-) create mode 100644 src/server/validate-project-dir.ts diff --git a/src/server/routes.ts b/src/server/routes.ts index 12f30ae2..cc84642f 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 00000000..6b8902ff --- /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/tests/unit/audit-regressions.test.ts b/tests/unit/audit-regressions.test.ts index 74885612..f7cf909a 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"); }); }); From eb0ae2dbbea89ca91b8e8a4f0b20d78889b93d5a Mon Sep 17 00:00:00 2001 From: almogdepaz Date: Wed, 13 May 2026 02:33:15 +0300 Subject: [PATCH 2/7] fix(terminal): warn loudly when ghostty-web repaint internals drift (closes #130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forceRepaint() pokes ghostty-web private fields (renderer, wasmTerm, viewportY) because no stable upstream API exists. A future bundle update that renames or restructures those fields would silently turn the call into a no-op, leaving stale frames after attach with no signal. - New public/terminal-repaint.ts exports hasGhosttyRepaintHook(term) feature-detecting the expected shape (pure, no side-effects). - forceRepaint() now probes via the helper and emits a single console.warn on shape drift before bailing — silent degradation becomes loud and diagnosable. - tests/unit/terminal-repaint.test.ts covers the positive case plus every drift mode (missing renderer, renamed field, non-function render, missing wasmTerm, missing viewportY, viewportY === 0). --- public/app.ts | 21 +++++++++- public/terminal-repaint.ts | 24 +++++++++++ tests/unit/terminal-repaint.test.ts | 63 +++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 public/terminal-repaint.ts create mode 100644 tests/unit/terminal-repaint.test.ts diff --git a/public/app.ts b/public/app.ts index 12024a0c..bea4fce8 100644 --- a/public/app.ts +++ b/public/app.ts @@ -8,6 +8,7 @@ import { SNAPSHOT_KEY_PREFIX, SNAPSHOT_MAX_BYTES, SNAPSHOT_SAVE_INTERVAL, DESKTOP_TERMINAL_SCROLLBACK, GRID_TERMINAL_SCROLLBACK, } from "./app-state"; +import { hasGhosttyRepaintHook } from "./terminal-repaint"; import { initRalphDeps, @@ -1217,13 +1218,31 @@ function createPtyTerminalController(opts) { } } + // Tracks whether we've already warned about a broken ghostty-web private + // repaint hook. If the internals shape changes in a future ghostty-web + // upgrade we emit a single console.warn so silent degradation is at least + // diagnosable (issue #130). + let _forceRepaintWarned = false; function forceRepaint() { if (!_term) return; const t = _term as any; + const available = hasGhosttyRepaintHook(t); + if (!available) { + if (!_forceRepaintWarned) { + _forceRepaintWarned = true; + console.warn( + "[wolfpack] forceRepaint: ghostty-web internals not detected" + + " (renderer/wasmTerm/viewportY). Terminal may show stale frames" + + " after attach until ghostty-web is updated or a stable repaint" + + " API is wired in." + ); + } + return; + } // renderer.render(buffer, forceAll, viewportY, scrollbackProvider) bypasses // Terminal.resize()'s same-dimension guard and FitAddon.fit()'s _lastCols guard. // This is the only way to force a full canvas repaint without changing dimensions. - try { t.renderer?.render(t.wasmTerm, true, t.viewportY, t); } catch {} + try { t.renderer.render(t.wasmTerm, true, t.viewportY, t); } catch {} } function syncLayout(options?: { forceSend?: boolean; repaint?: boolean; reason?: string }) { diff --git a/public/terminal-repaint.ts b/public/terminal-repaint.ts new file mode 100644 index 00000000..2da1db9b --- /dev/null +++ b/public/terminal-repaint.ts @@ -0,0 +1,24 @@ +/** + * Feature-detection helpers for ghostty-web private internals. + * + * The terminal forceRepaint() path in app.ts pokes ghostty-web private fields + * (renderer, wasmTerm, viewportY) because no stable repaint API exists upstream. + * Any ghostty-web bundle update that renames or restructures these fields + * would silently turn forceRepaint() into a no-op (issue #130). Centralising + * the shape check here makes it diagnosable (a single console.warn fires) and + * testable in isolation. + */ + +/** + * Returns true when the ghostty-web Terminal instance exposes the private + * fields forceRepaint() depends on. False means the upstream contract drifted + * and the caller must surface a warning + skip the repaint. + */ +export function hasGhosttyRepaintHook(term: unknown): boolean { + if (!term || typeof term !== "object") return false; + const t = term as { renderer?: { render?: unknown }; wasmTerm?: unknown; viewportY?: unknown }; + if (!t.renderer || typeof t.renderer.render !== "function") return false; + if (t.wasmTerm === undefined) return false; + if (!("viewportY" in t)) return false; + return true; +} diff --git a/tests/unit/terminal-repaint.test.ts b/tests/unit/terminal-repaint.test.ts new file mode 100644 index 00000000..423aa050 --- /dev/null +++ b/tests/unit/terminal-repaint.test.ts @@ -0,0 +1,63 @@ +/** + * Regression coverage for issue #130: defensive feature-detection around the + * ghostty-web private repaint hook so a future bundle update that renames or + * restructures `renderer` / `wasmTerm` / `viewportY` triggers a loud warning + * instead of a silent no-op. + */ +import { describe, expect, test } from "bun:test"; +import { hasGhosttyRepaintHook } from "../../public/terminal-repaint.ts"; + +describe("hasGhosttyRepaintHook — ghostty-web internals probe", () => { + test("returns true for a terminal exposing the expected shape", () => { + const term = { + renderer: { render: () => {} }, + wasmTerm: { _: "opaque" }, + viewportY: 0, + }; + expect(hasGhosttyRepaintHook(term)).toBe(true); + }); + + test("accepts viewportY === 0 (falsy but present)", () => { + const term = { renderer: { render: () => {} }, wasmTerm: {}, viewportY: 0 }; + expect(hasGhosttyRepaintHook(term)).toBe(true); + }); + + test("rejects when renderer is missing", () => { + expect(hasGhosttyRepaintHook({ wasmTerm: {}, viewportY: 0 })).toBe(false); + }); + + test("rejects when renderer.render is not a function", () => { + expect( + hasGhosttyRepaintHook({ renderer: { render: 123 }, wasmTerm: {}, viewportY: 0 }), + ).toBe(false); + }); + + test("rejects when wasmTerm is undefined", () => { + expect( + hasGhosttyRepaintHook({ renderer: { render: () => {} }, viewportY: 0 }), + ).toBe(false); + }); + + test("rejects when viewportY is absent", () => { + expect( + hasGhosttyRepaintHook({ renderer: { render: () => {} }, wasmTerm: {} }), + ).toBe(false); + }); + + test("rejects null / undefined / non-object", () => { + expect(hasGhosttyRepaintHook(null)).toBe(false); + expect(hasGhosttyRepaintHook(undefined)).toBe(false); + expect(hasGhosttyRepaintHook("term")).toBe(false); + expect(hasGhosttyRepaintHook(42)).toBe(false); + }); + + test("rejects a renamed renderer field (simulates upstream drift)", () => { + const term = { + // ghostty-web hypothetically renames this in a future release + renderEngine: { render: () => {} }, + wasmTerm: {}, + viewportY: 0, + }; + expect(hasGhosttyRepaintHook(term)).toBe(false); + }); +}); From def456d2646ac6fdd1ed9403aab64f0f05a880ca Mon Sep 17 00:00:00 2001 From: almogdepaz Date: Wed, 13 May 2026 02:36:05 +0300 Subject: [PATCH 3/7] fix(ws): cap quiescence wait at 200ms for animated TUIs (closes #129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animated TUIs (spinners, htop, watch, claude's pulse spinner) never let the broker byte rate drop below QUIESCE_BYTE_THRESHOLD, so the settle loop in the prefill path always waited the full QUIESCE_TIMEOUT_MS (800ms) before snapshotting. The snapshot was taken mid-redraw and the live stream then had to overwrite the garbled frame — visible UX artifact lasting 150–800ms on attach. - Add QUIESCE_ANIMATED_CAP_MS = 200. When byte rate stays above the threshold through the entire window, snapshot at 200ms instead of waiting out the full timeout. Cuts the worst-case mid-redraw prefill window 4x. - Common case (apps that actually quiesce) is unchanged — they exit via the 'quiet' branch within ~100ms, well before the cap. - Extract the decision into a pure quiescenceDecision() function so the loop's three (now four) exit conditions are testable in isolation without a real broker. The setTimeout(16) wait and resize side-effects stay at the call site. - Add log.debug for animated_cap / timeout exits so operators can spot always-busy sessions in traces. --- src/server/websocket.ts | 62 +++++++++-- tests/unit/quiescence-decision.test.ts | 145 +++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 tests/unit/quiescence-decision.test.ts diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 3085c594..17ffdae4 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/quiescence-decision.test.ts b/tests/unit/quiescence-decision.test.ts new file mode 100644 index 00000000..06a075de --- /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"); + }); +}); From 13cd4469c07c70ae2f9167c3b9fd7d4fb0450b90 Mon Sep 17 00:00:00 2001 From: almogdepaz Date: Wed, 13 May 2026 12:48:37 +0300 Subject: [PATCH 4/7] Revert "fix(terminal): warn loudly when ghostty-web repaint internals drift (closes #130)" This reverts commit eb0ae2dbbea89ca91b8e8a4f0b20d78889b93d5a. --- public/app.ts | 21 +--------- public/terminal-repaint.ts | 24 ----------- tests/unit/terminal-repaint.test.ts | 63 ----------------------------- 3 files changed, 1 insertion(+), 107 deletions(-) delete mode 100644 public/terminal-repaint.ts delete mode 100644 tests/unit/terminal-repaint.test.ts diff --git a/public/app.ts b/public/app.ts index bea4fce8..12024a0c 100644 --- a/public/app.ts +++ b/public/app.ts @@ -8,7 +8,6 @@ import { SNAPSHOT_KEY_PREFIX, SNAPSHOT_MAX_BYTES, SNAPSHOT_SAVE_INTERVAL, DESKTOP_TERMINAL_SCROLLBACK, GRID_TERMINAL_SCROLLBACK, } from "./app-state"; -import { hasGhosttyRepaintHook } from "./terminal-repaint"; import { initRalphDeps, @@ -1218,31 +1217,13 @@ function createPtyTerminalController(opts) { } } - // Tracks whether we've already warned about a broken ghostty-web private - // repaint hook. If the internals shape changes in a future ghostty-web - // upgrade we emit a single console.warn so silent degradation is at least - // diagnosable (issue #130). - let _forceRepaintWarned = false; function forceRepaint() { if (!_term) return; const t = _term as any; - const available = hasGhosttyRepaintHook(t); - if (!available) { - if (!_forceRepaintWarned) { - _forceRepaintWarned = true; - console.warn( - "[wolfpack] forceRepaint: ghostty-web internals not detected" + - " (renderer/wasmTerm/viewportY). Terminal may show stale frames" + - " after attach until ghostty-web is updated or a stable repaint" + - " API is wired in." - ); - } - return; - } // renderer.render(buffer, forceAll, viewportY, scrollbackProvider) bypasses // Terminal.resize()'s same-dimension guard and FitAddon.fit()'s _lastCols guard. // This is the only way to force a full canvas repaint without changing dimensions. - try { t.renderer.render(t.wasmTerm, true, t.viewportY, t); } catch {} + try { t.renderer?.render(t.wasmTerm, true, t.viewportY, t); } catch {} } function syncLayout(options?: { forceSend?: boolean; repaint?: boolean; reason?: string }) { diff --git a/public/terminal-repaint.ts b/public/terminal-repaint.ts deleted file mode 100644 index 2da1db9b..00000000 --- a/public/terminal-repaint.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Feature-detection helpers for ghostty-web private internals. - * - * The terminal forceRepaint() path in app.ts pokes ghostty-web private fields - * (renderer, wasmTerm, viewportY) because no stable repaint API exists upstream. - * Any ghostty-web bundle update that renames or restructures these fields - * would silently turn forceRepaint() into a no-op (issue #130). Centralising - * the shape check here makes it diagnosable (a single console.warn fires) and - * testable in isolation. - */ - -/** - * Returns true when the ghostty-web Terminal instance exposes the private - * fields forceRepaint() depends on. False means the upstream contract drifted - * and the caller must surface a warning + skip the repaint. - */ -export function hasGhosttyRepaintHook(term: unknown): boolean { - if (!term || typeof term !== "object") return false; - const t = term as { renderer?: { render?: unknown }; wasmTerm?: unknown; viewportY?: unknown }; - if (!t.renderer || typeof t.renderer.render !== "function") return false; - if (t.wasmTerm === undefined) return false; - if (!("viewportY" in t)) return false; - return true; -} diff --git a/tests/unit/terminal-repaint.test.ts b/tests/unit/terminal-repaint.test.ts deleted file mode 100644 index 423aa050..00000000 --- a/tests/unit/terminal-repaint.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Regression coverage for issue #130: defensive feature-detection around the - * ghostty-web private repaint hook so a future bundle update that renames or - * restructures `renderer` / `wasmTerm` / `viewportY` triggers a loud warning - * instead of a silent no-op. - */ -import { describe, expect, test } from "bun:test"; -import { hasGhosttyRepaintHook } from "../../public/terminal-repaint.ts"; - -describe("hasGhosttyRepaintHook — ghostty-web internals probe", () => { - test("returns true for a terminal exposing the expected shape", () => { - const term = { - renderer: { render: () => {} }, - wasmTerm: { _: "opaque" }, - viewportY: 0, - }; - expect(hasGhosttyRepaintHook(term)).toBe(true); - }); - - test("accepts viewportY === 0 (falsy but present)", () => { - const term = { renderer: { render: () => {} }, wasmTerm: {}, viewportY: 0 }; - expect(hasGhosttyRepaintHook(term)).toBe(true); - }); - - test("rejects when renderer is missing", () => { - expect(hasGhosttyRepaintHook({ wasmTerm: {}, viewportY: 0 })).toBe(false); - }); - - test("rejects when renderer.render is not a function", () => { - expect( - hasGhosttyRepaintHook({ renderer: { render: 123 }, wasmTerm: {}, viewportY: 0 }), - ).toBe(false); - }); - - test("rejects when wasmTerm is undefined", () => { - expect( - hasGhosttyRepaintHook({ renderer: { render: () => {} }, viewportY: 0 }), - ).toBe(false); - }); - - test("rejects when viewportY is absent", () => { - expect( - hasGhosttyRepaintHook({ renderer: { render: () => {} }, wasmTerm: {} }), - ).toBe(false); - }); - - test("rejects null / undefined / non-object", () => { - expect(hasGhosttyRepaintHook(null)).toBe(false); - expect(hasGhosttyRepaintHook(undefined)).toBe(false); - expect(hasGhosttyRepaintHook("term")).toBe(false); - expect(hasGhosttyRepaintHook(42)).toBe(false); - }); - - test("rejects a renamed renderer field (simulates upstream drift)", () => { - const term = { - // ghostty-web hypothetically renames this in a future release - renderEngine: { render: () => {} }, - wasmTerm: {}, - viewportY: 0, - }; - expect(hasGhosttyRepaintHook(term)).toBe(false); - }); -}); From 5e4f63659c4d3922f4250245dadb74dc36dd91c4 Mon Sep 17 00:00:00 2001 From: almogdepaz Date: Wed, 13 May 2026 13:35:37 +0300 Subject: [PATCH 5/7] docs: tighten README; extract dev docs to CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: 302 → 155 lines. - Lead with what it does + why (one paragraph), not marketing tagline. - Primary install is the no-deps curl|bash; bunx/npx hidden in
. - Drop tailscale 'optional' hedging — it's how you actually use this. - Promote security setup ('Secure It' with JWT) above features so anyone deploying on a tailnet sees it before scrolling past the demos. - Collapse the Features grab-bag (Session Management / Desktop / Mobile / Multi-Machine / Other / Remote Access / Security all flat-bulleted) into one terse 'What It Does' list, ~8 cross-cutting bullets. - Drop Contributing / Testing / Building / Asset Pipeline / PR Conventions / migrate-plan — all moved to CONTRIBUTING.md. - Drop 'Community & Support' boilerplate, garbled emoji, 'star the repo' growth-hack copy. - Cut from 4 screenshots to 2 (one desktop, one mobile). - Keep wolf ASCII art + architecture diagram + config example. CONTRIBUTING.md: new. Holds dev setup, testing, asset pipeline, build, PR conventions, and the migrate-plan utility. Verified empirically before writing: both image refs exist, all 7 CLI commands exist in src/cli/index.ts, all 4 JWT env vars exist in src/auth.ts, both doc links resolve. --- CONTRIBUTING.md | 67 ++++++++++++ README.md | 273 +++++++++++------------------------------------- 2 files changed, 130 insertions(+), 210 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..94289d87 --- /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 2530bc5b..31570081 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,105 @@ :+**++++++*++*+=-:: .. ...... .. .:..:: ``` -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 +## Install ```bash -bunx wolfpack-bridge +curl -fsSL https://raw.githubusercontent.com/almogdepaz/wolfpack/main/install.sh | bash ``` -Or with npx: - -```bash -npx wolfpack-bridge -``` +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. -Or via shell script (no Node/Bun required): +
+Alternative: install via Bun / npm ```bash -curl -fsSL https://raw.githubusercontent.com/almogdepaz/wolfpack/main/install.sh | bash +bunx wolfpack-bridge # or: npx wolfpack-bridge ``` -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). +Supported: macOS (arm64/x64), Linux (x64/arm64). -### Prerequisites +## First Run -- **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. +`wolfpack` walks you through: -No other runtime dependencies. The broker is bundled. +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 -### Session Persistence +Scan the QR with your phone, tap **Add to Home Screen**, done. -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. +## Secure It -The broker is started by `wolfpack service install` (alongside the server) and is checked by `wolfpack doctor`. - -## Usage +Anyone who can reach the server has full session access. Tailscale gates network reachability; **JWT** gates auth on the server itself. You want both. ```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) +export WOLFPACK_JWT_SECRET="$(openssl rand -base64 48)" ``` -### 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 - -### 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. - -All settings (font size, haptics, enter-sends, snapshot TTL, etc.) persist in localStorage across sessions. - -### 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 - -### 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 - -### Remote Access - -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 +Set this before starting wolfpack (or in your service environment). Tokens are HS256; the server validates, it does not issue — sign them with any JWT library using the same secret. -Tailscale's encrypted mesh network handles auth and routing — no ports to open, no DNS to configure. +Optional env vars: `WOLFPACK_JWT_AUDIENCE`, `WOLFPACK_JWT_ISSUER`, `WOLFPACK_JWT_CLOCK_TOLERANCE_SEC` (default 30s). -### Security +If `WOLFPACK_JWT_SECRET` is unset, auth is disabled — fine for localhost-only, **not** fine on a tailnet. -**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. +## What It Does -**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: +- **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). -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. +## CLI -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) - -Tokens use HS256 (HMAC-SHA256). The server validates but does not issue tokens — generate them with any JWT library using the same secret. +``` +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 +``` -**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. +## Architecture -## Ralph Loop +``` +┌─────────────┐ ┌───────────┐ ┌──────────────────────────────────────────┐ +│ Phone / │ │ Tailscale │ │ Your Machine │ +│ Browser │◄──►│ (HTTPS) │◄──►│ │ +│ (PWA) │ │ mesh VPN │ │ ┌──────────┐ unix ┌──────────────┐ │ +└─────────────┘ └───────────┘ │ │ wolfpack │ socket │ wolfpack- │ │ + │ │ server │◄───────►│ broker │ │ + │ │ (Bun) │ │ (Rust, PTY) │ │ + │ │ HTTP/WS │ │ owns agents │ │ + │ └──────────┘ └──────────────┘ │ + └──────────────────────────────────────────┘ +``` -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). +- **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). ## Config -Stored in `~/.wolfpack/config.json` (mode 0600): +`~/.wolfpack/config.json` (mode 0600): ```json { @@ -231,71 +142,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 From c2cf96b96f31f6e89222066cf2287f482033f439 Mon Sep 17 00:00:00 2001 From: almogdepaz Date: Wed, 13 May 2026 13:39:01 +0300 Subject: [PATCH 6/7] docs(readme): demote JWT to optional section, drop 'you want both' framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailscale already gates network reachability — for the common solo-user case that's enough. JWT is now framed as opt-in (shared tailnets, defense-in-depth), positioned after Architecture rather than between First Run and What It Does. --- README.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 31570081..d8363e12 100644 --- a/README.md +++ b/README.md @@ -74,20 +74,6 @@ Supported: macOS (arm64/x64), Linux (x64/arm64). Scan the QR with your phone, tap **Add to Home Screen**, done. -## Secure It - -Anyone who can reach the server has full session access. Tailscale gates network reachability; **JWT** gates auth on the server itself. You want both. - -```bash -export WOLFPACK_JWT_SECRET="$(openssl rand -base64 48)" -``` - -Set this before starting wolfpack (or in your service environment). Tokens are HS256; the server validates, it does not issue — sign them with any JWT library using the same secret. - -Optional env vars: `WOLFPACK_JWT_AUDIENCE`, `WOLFPACK_JWT_ISSUER`, `WOLFPACK_JWT_CLOCK_TOLERANCE_SEC` (default 30s). - -If `WOLFPACK_JWT_SECRET` is unset, auth is disabled — fine for localhost-only, **not** fine on a tailnet. - ## What It Does - **Multi-machine** — one phone manages sessions on every machine on your tailnet. Online/offline status per machine, cross-machine session list. @@ -130,6 +116,18 @@ wolfpack uninstall --yes Remove everything - **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). +## Optional: JWT Auth + +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: + +```bash +export WOLFPACK_JWT_SECRET="$(openssl rand -base64 48)" +``` + +Tokens are HS256; the server validates, it does not issue — sign them with any JWT library using the same secret. + +Optional: `WOLFPACK_JWT_AUDIENCE`, `WOLFPACK_JWT_ISSUER`, `WOLFPACK_JWT_CLOCK_TOLERANCE_SEC` (default 30s). + ## Config `~/.wolfpack/config.json` (mode 0600): From bfc6b81956d704e657f328048b961d33b5a0a27f Mon Sep 17 00:00:00 2001 From: almogdepaz Date: Wed, 13 May 2026 13:47:42 +0300 Subject: [PATCH 7/7] docs(readme): restore desktop grid screenshot --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d8363e12..13638e42 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ Drive AI coding agents (Claude, Codex, Gemini, any shell command) from your phon

Desktop — terminal with collapsible sidebar

+

+ Desktop — multi-terminal grid view +

Mobile — session list across machines