From 71ab821abd95613cac092a4b406541d890205abc Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 17:38:38 +0200 Subject: [PATCH] worker: make/jq/gopls in base image + per-repo .lvmh/setup.sh hook (agent-driven persistent setup) --- deploy/rsync-pi-agent.sh | 34 +-- docker/README.md | 28 +++ docker/bridge/index.mjs | 38 +++ docker/worker.Dockerfile | 4 +- web/src/SpawnView.tsx | 489 +++++++++++++++++++++------------------ 5 files changed, 347 insertions(+), 246 deletions(-) create mode 100644 docker/README.md diff --git a/deploy/rsync-pi-agent.sh b/deploy/rsync-pi-agent.sh index 45411dd..6700f35 100755 --- a/deploy/rsync-pi-agent.sh +++ b/deploy/rsync-pi-agent.sh @@ -7,27 +7,27 @@ SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}" DEST="$(dirname "$0")/../docker/pi-agent" if [ ! -f "$SRC/settings.json" ]; then - echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2 - exit 0 + echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2 + exit 0 fi mkdir -p "$DEST" rsync -a --delete \ - --exclude 'auth.json' \ - --exclude 'models.json' \ - --exclude 'models-store.json' \ - --exclude 'sessions/' \ - --exclude 'cache/' \ - --exclude 'npm/' \ - --exclude 'git/' \ - --exclude 'trust.json' \ - --exclude 'mcp-*' \ - --exclude 'run-history*' \ - --exclude 'vibes' \ - --exclude '*.log' \ - --exclude 'scratch/' \ - --exclude '.pi/' \ - "$SRC/" "$DEST/" + --exclude 'auth.json' \ + --exclude 'models.json' \ + --exclude 'models-store.json' \ + --exclude 'sessions/' \ + --exclude 'cache/' \ + --exclude 'npm/' \ + --exclude 'git/' \ + --exclude 'trust.json' \ + --exclude 'mcp-*' \ + --exclude 'run-history*' \ + --exclude 'vibes' \ + --exclude '*.log' \ + --exclude 'scratch/' \ + --exclude '.pi/' \ + "$SRC/" "$DEST/" # Container sessions cannot run git: package postinstalls (e.g. husky) — keep # registry (npm:) packages only in the baked settings.json. diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..ad5a032 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,28 @@ +# docker/ — worker image & bridge + +## lvmh-worker image (`worker.Dockerfile`) + +Headless pi worker: node 24, pi (global npm), golang, git, ripgrep, make, jq, +gopls, plus the user's pi config baked from dotfiles (`deploy/rsync-pi-agent.sh`: +settings/skills/agents/extensions; no auth.json/sessions/cache; `git:` packages +dropped — postinstalls crash headless installs). + +## Per-repo setup hook (agent-driven image setup) + +The agent works as root with network access, so it can `apt-get install` / +`go install` anything **in-session**. Containers are ephemeral, so system +packages vanish on container stop. For durability, a repo can ship: + + /workspace/.lvmh/setup.sh + +The bridge (`bridge/index.mjs`) runs it with bash on **every spawn** before the +pi session starts (10-min timeout, failure is non-fatal — logged, session +continues). Since `/workspace` is a persistent per-repo volume, the hook +survives container death. Agents that need extra tooling should write that +script into the repo (e.g. `apt-get update && apt-get install -y sqlite3`). + +## bridge/index.mjs + +Hosts the SDK session (`createAgentSession` + `bindExtensions({mode:"rpc"})` — +the SDK never fires `session_start` without the explicit bind, and the lvmh +plugin dials home off that event). diff --git a/docker/bridge/index.mjs b/docker/bridge/index.mjs index 845a704..ac6d482 100644 --- a/docker/bridge/index.mjs +++ b/docker/bridge/index.mjs @@ -2,13 +2,51 @@ // at /root/.pi/agent/extensions/lvmh-agent.ts) dials the daemon and delivers // web prompts through pi.sendUserMessage. This process just hosts the session. // Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM). +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { createAgentSession } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; +const SETUP_PATH = "/workspace/.lvmh/setup.sh"; +const SETUP_TIMEOUT_MS = 10 * 60 * 1000; + +// Repo-level setup hook: if the repo ships .lvmh/setup.sh, run it before the +// session starts (apt-get/go install/pip — anything the agent needs). +// /workspace is a persistent per-repo volume, so the hook survives container +// restarts and re-runs on every spawn. Failure is non-fatal: log and continue. +async function runRepoSetup() { + if (!existsSync(SETUP_PATH)) return; + console.error(`[lvmh-bridge] running repo setup: ${SETUP_PATH}`); + const code = await new Promise((resolve) => { + const child = spawn("bash", [SETUP_PATH], { + cwd: "/workspace", + stdio: ["ignore", "inherit", "inherit"], + }); + const timer = setTimeout(() => { + console.error(`[lvmh-bridge] setup timed out after ${SETUP_TIMEOUT_MS}ms, killing`); + child.kill("SIGKILL"); + resolve(124); + }, SETUP_TIMEOUT_MS); + timer.unref?.(); + child.on("error", (err) => { + clearTimeout(timer); + console.error("[lvmh-bridge] setup spawn error:", err); + resolve(1); + }); + child.on("exit", (c) => { + clearTimeout(timer); + resolve(c ?? 1); + }); + }); + if (code === 0) console.error("[lvmh-bridge] setup completed"); + else console.error(`[lvmh-bridge] setup exited ${code} — continuing anyway`); +} + process.on("unhandledRejection", (err) => { console.error("[lvmh-bridge] unhandledRejection:", err); }); try { + await runRepoSetup(); const { session } = await createAgentSession(); // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without // bindExtensions the session_start event never fires, so the lvmh plugin diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile index f98c6ef..cfd4a22 100644 --- a/docker/worker.Dockerfile +++ b/docker/worker.Dockerfile @@ -5,10 +5,12 @@ FROM node:24-bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - bash ca-certificates git ripgrep curl xz-utils \ + bash ca-certificates git ripgrep curl xz-utils make jq \ golang-go \ && rm -rf /var/lib/apt/lists/* +RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest || true + RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent # User's pi config from dotfiles (settings, skills, agents, extensions, diff --git a/web/src/SpawnView.tsx b/web/src/SpawnView.tsx index a283d70..5703a5b 100644 --- a/web/src/SpawnView.tsx +++ b/web/src/SpawnView.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import type { GitlabStatus, Repo, SessionListItem, SpawnJob, SpawnResponse } from "./protocol"; +import type { + GitlabStatus, + Repo, + SessionListItem, + SpawnJob, + SpawnResponse, +} from "./protocol"; import { Route } from "./protocol"; import { errMessage, fetchJson } from "./api"; import type { SessionsStore } from "./store"; @@ -9,252 +15,279 @@ const POLL_MS: number = 1500; const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish) interface Props { - store: SessionsStore; - pushToast: (text: string) => void; + store: SessionsStore; + pushToast: (text: string) => void; } export default function SpawnView({ store, pushToast }: Props) { - const navigate = useNavigate(); + const navigate = useNavigate(); - const [status, setStatus] = useState(null); - const [pat, setPat] = useState(""); - const [repos, setRepos] = useState(null); - const [query, setQuery] = useState(""); - const [selected, setSelected] = useState(null); - const [branch, setBranch] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [spawning, setSpawning] = useState(null); + const [status, setStatus] = useState(null); + const [pat, setPat] = useState(""); + const [repos, setRepos] = useState(null); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [branch, setBranch] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [spawning, setSpawning] = useState(null); - const timerRef = useRef(null); - const tickRef = useRef(0); + const timerRef = useRef(null); + const tickRef = useRef(0); - useEffect(() => { - return () => { - if (timerRef.current !== null) window.clearInterval(timerRef.current); - }; - }, []); + useEffect(() => { + return () => { + if (timerRef.current !== null) window.clearInterval(timerRef.current); + }; + }, []); - const loadStatus = async (): Promise => { - const s = await fetchJson(Route.GitlabStatus); - setStatus(s); - return s; - }; + const loadStatus = async (): Promise => { + const s = await fetchJson(Route.GitlabStatus); + setStatus(s); + return s; + }; - const loadRepos = async (): Promise => { - const list = await fetchJson(Route.GitlabRepos); - setRepos(list); - }; + const loadRepos = async (): Promise => { + const list = await fetchJson(Route.GitlabRepos); + setRepos(list); + }; - useEffect(() => { - void (async () => { - try { - const s = await loadStatus(); - if (s.connected) await loadRepos(); - } catch (err) { - setError(errMessage(err)); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useEffect(() => { + void (async () => { + try { + const s = await loadStatus(); + if (s.connected) await loadRepos(); + } catch (err) { + setError(errMessage(err)); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const connect = async (): Promise => { - if (pat.trim().length === 0) { - setError("token required"); - return; - } - setBusy(true); - setError(""); - try { - await fetchJson(Route.GitlabConnect, { method: "POST", body: JSON.stringify({ token: pat.trim() }) }); - setPat(""); - await loadStatus(); - await loadRepos(); - } catch (err) { - setError(errMessage(err)); - } finally { - setBusy(false); - } - }; + const connect = async (): Promise => { + if (pat.trim().length === 0) { + setError("token required"); + return; + } + setBusy(true); + setError(""); + try { + await fetchJson(Route.GitlabConnect, { + method: "POST", + body: JSON.stringify({ token: pat.trim() }), + }); + setPat(""); + await loadStatus(); + await loadRepos(); + } catch (err) { + setError(errMessage(err)); + } finally { + setBusy(false); + } + }; - const pick = (repo: Repo): void => { - setSelected(repo); - setBranch(repo.defaultBranch); - }; + const pick = (repo: Repo): void => { + setSelected(repo); + setBranch(repo.defaultBranch); + }; - const filtered: Repo[] = (repos ?? []).filter((r) => r.path.toLowerCase().includes(query.toLowerCase())); + const filtered: Repo[] = (repos ?? []).filter((r) => + r.path.toLowerCase().includes(query.toLowerCase()), + ); - const spawn = async (): Promise => { - if (selected === null) { - setError("pick a repo first"); - return; - } - setBusy(true); - setError(""); - try { - const body = { repo: selected.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}) }; - const res = await fetchJson(Route.Spawn, { method: "POST", body: JSON.stringify(body) }); - setSpawning(res); - startPolling(res.sessionId); - } catch (err) { - setError(errMessage(err)); - } finally { - setBusy(false); - } - }; + const spawn = async (): Promise => { + if (selected === null) { + setError("pick a repo first"); + return; + } + setBusy(true); + setError(""); + try { + const body = { + repo: selected.path, + ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), + }; + const res = await fetchJson(Route.Spawn, { + method: "POST", + body: JSON.stringify(body), + }); + setSpawning(res); + startPolling(res.sessionId); + } catch (err) { + setError(errMessage(err)); + } finally { + setBusy(false); + } + }; - const startPolling = (sessionId: string): void => { - tickRef.current = 0; - if (timerRef.current !== null) window.clearInterval(timerRef.current); - timerRef.current = window.setInterval(() => { - tickRef.current += 1; - if (tickRef.current > POLL_MAX_TICKS) { - if (timerRef.current !== null) window.clearInterval(timerRef.current); - timerRef.current = null; - return; - } - void (async () => { - try { - const list = await fetchJson(Route.Sessions); - const s = list.find((x) => x.id === sessionId); - await store.refresh(); - if (s !== undefined && s.online) { - if (timerRef.current !== null) window.clearInterval(timerRef.current); - timerRef.current = null; - navigate(`/s/${sessionId}`); - } - } catch (err) { - pushToast(errMessage(err)); - } - })(); - }, POLL_MS); - }; + const startPolling = (sessionId: string): void => { + tickRef.current = 0; + if (timerRef.current !== null) window.clearInterval(timerRef.current); + timerRef.current = window.setInterval(() => { + tickRef.current += 1; + if (tickRef.current > POLL_MAX_TICKS) { + if (timerRef.current !== null) window.clearInterval(timerRef.current); + timerRef.current = null; + return; + } + void (async () => { + try { + const list = await fetchJson(Route.Sessions); + const s = list.find((x) => x.id === sessionId); + await store.refresh(); + if (s !== undefined && s.online) { + if (timerRef.current !== null) + window.clearInterval(timerRef.current); + timerRef.current = null; + navigate(`/s/${sessionId}`); + } + } catch (err) { + pushToast(errMessage(err)); + } + })(); + }, POLL_MS); + }; - const jobLine = (): string => { - if (spawning === null) return ""; - const job: SpawnJob | undefined = store.spawnJobs.find((j) => j.sessionId === spawning.sessionId); - if (job !== undefined) return `${job.repo}: ${job.state}`; - return "waiting for session to come online…"; - }; + const jobLine = (): string => { + if (spawning === null) return ""; + const job: SpawnJob | undefined = store.spawnJobs.find( + (j) => j.sessionId === spawning.sessionId, + ); + if (job !== undefined) return `${job.repo}: ${job.state}`; + return "waiting for session to come online…"; + }; - if (status === null) { - return ( -
-

Spawn

-

{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}

-
- ); - } + if (status === null) { + return ( +
+

Spawn

+

+ {error.length > 0 + ? `gitlab status failed: ${error}` + : "checking gitlab…"} +

+
+ ); + } - if (!status.connected) { - return ( -
-

Spawn

-
{ - e.preventDefault(); - void connect(); - }} - > -

Connect GitLab

-

- Paste a personal access token (api scope). Stored daemon-side only. -

- setPat(e.target.value)} - /> - {error.length > 0 &&

{error}

} -
- -
-
-
- ); - } + if (!status.connected) { + return ( +
+

Spawn

+
{ + e.preventDefault(); + void connect(); + }} + > +

Connect GitLab

+

+ Paste a personal access token (api scope). Stored daemon-side only. +

+ setPat(e.target.value)} + /> + {error.length > 0 &&

{error}

} +
+ +
+
+
+ ); + } - return ( -
-

Spawn

+ return ( +
+

Spawn

- {spawning !== null && ( -
-

Spawning…

-

{jobLine()}

-

container {spawning.containerId.slice(0, 12)}

- {error.length > 0 &&

{error}

} -
- )} + {spawning !== null && ( +
+

Spawning…

+

{jobLine()}

+

+ container {spawning.containerId.slice(0, 12)} +

+ {error.length > 0 &&

{error}

} +
+ )} - {spawning === null && ( - <> -
-

Repository

- setQuery(e.target.value)} - /> - {repos === null &&

loading repos…

} - {repos !== null && filtered.length === 0 &&

no matching repos

} -
- {filtered.map((r) => ( -
pick(r)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") pick(r); - }} - > -
-
{r.path}
-
- default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)} -
-
-
- ))} -
-
+ {spawning === null && ( + <> +
+

Repository

+ setQuery(e.target.value)} + /> + {repos === null &&

loading repos…

} + {repos !== null && filtered.length === 0 && ( +

no matching repos

+ )} +
+ {filtered.map((r) => ( +
pick(r)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") pick(r); + }} + > +
+
{r.path}
+
+ default {r.defaultBranch} ·{" "} + {r.lastActivityAt.slice(0, 10)} +
+
+
+ ))} +
+
-
-

Branch

-
- setBranch(e.target.value)} - /> - -
- {selected === null &&

select a repo above

} - {error.length > 0 &&

{error}

} -
- - )} -
- ); +
+

Branch

+
+ setBranch(e.target.value)} + /> + +
+ {selected === null && ( +

+ select a repo above +

+ )} + {error.length > 0 &&

{error}

} +
+ + )} +
+ ); }