worker: make/jq/gopls in base image + per-repo .lvmh/setup.sh hook (agent-driven persistent setup)

This commit is contained in:
Raphael Westphal
2026-08-18 17:38:38 +02:00
parent df91ba6c4c
commit 71ab821abd
5 changed files with 347 additions and 246 deletions
+17 -17
View File
@@ -7,27 +7,27 @@ SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}"
DEST="$(dirname "$0")/../docker/pi-agent" DEST="$(dirname "$0")/../docker/pi-agent"
if [ ! -f "$SRC/settings.json" ]; then if [ ! -f "$SRC/settings.json" ]; then
echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2 echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2
exit 0 exit 0
fi fi
mkdir -p "$DEST" mkdir -p "$DEST"
rsync -a --delete \ rsync -a --delete \
--exclude 'auth.json' \ --exclude 'auth.json' \
--exclude 'models.json' \ --exclude 'models.json' \
--exclude 'models-store.json' \ --exclude 'models-store.json' \
--exclude 'sessions/' \ --exclude 'sessions/' \
--exclude 'cache/' \ --exclude 'cache/' \
--exclude 'npm/' \ --exclude 'npm/' \
--exclude 'git/' \ --exclude 'git/' \
--exclude 'trust.json' \ --exclude 'trust.json' \
--exclude 'mcp-*' \ --exclude 'mcp-*' \
--exclude 'run-history*' \ --exclude 'run-history*' \
--exclude 'vibes' \ --exclude 'vibes' \
--exclude '*.log' \ --exclude '*.log' \
--exclude 'scratch/' \ --exclude 'scratch/' \
--exclude '.pi/' \ --exclude '.pi/' \
"$SRC/" "$DEST/" "$SRC/" "$DEST/"
# Container sessions cannot run git: package postinstalls (e.g. husky) — keep # Container sessions cannot run git: package postinstalls (e.g. husky) — keep
# registry (npm:) packages only in the baked settings.json. # registry (npm:) packages only in the baked settings.json.
+28
View File
@@ -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).
+38
View File
@@ -2,13 +2,51 @@
// at /root/.pi/agent/extensions/lvmh-agent.ts) dials the daemon and delivers // at /root/.pi/agent/extensions/lvmh-agent.ts) dials the daemon and delivers
// web prompts through pi.sendUserMessage. This process just hosts the session. // 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). // 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"; 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) => { process.on("unhandledRejection", (err) => {
console.error("[lvmh-bridge] unhandledRejection:", err); console.error("[lvmh-bridge] unhandledRejection:", err);
}); });
try { try {
await runRepoSetup();
const { session } = await createAgentSession(); const { session } = await createAgentSession();
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
// bindExtensions the session_start event never fires, so the lvmh plugin // bindExtensions the session_start event never fires, so the lvmh plugin
+3 -1
View File
@@ -5,10 +5,12 @@ FROM node:24-bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && 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 \ golang-go \
&& rm -rf /var/lib/apt/lists/* && 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 RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# User's pi config from dotfiles (settings, skills, agents, extensions, # User's pi config from dotfiles (settings, skills, agents, extensions,
+261 -228
View File
@@ -1,6 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { Route } from "./protocol";
import { errMessage, fetchJson } from "./api"; import { errMessage, fetchJson } from "./api";
import type { SessionsStore } from "./store"; 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) const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
interface Props { interface Props {
store: SessionsStore; store: SessionsStore;
pushToast: (text: string) => void; pushToast: (text: string) => void;
} }
export default function SpawnView({ store, pushToast }: Props) { export default function SpawnView({ store, pushToast }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<GitlabStatus | null>(null); const [status, setStatus] = useState<GitlabStatus | null>(null);
const [pat, setPat] = useState<string>(""); const [pat, setPat] = useState<string>("");
const [repos, setRepos] = useState<Repo[] | null>(null); const [repos, setRepos] = useState<Repo[] | null>(null);
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [selected, setSelected] = useState<Repo | null>(null); const [selected, setSelected] = useState<Repo | null>(null);
const [branch, setBranch] = useState<string>(""); const [branch, setBranch] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [spawning, setSpawning] = useState<SpawnResponse | null>(null); const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const tickRef = useRef<number>(0); const tickRef = useRef<number>(0);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (timerRef.current !== null) window.clearInterval(timerRef.current); if (timerRef.current !== null) window.clearInterval(timerRef.current);
}; };
}, []); }, []);
const loadStatus = async (): Promise<GitlabStatus> => { const loadStatus = async (): Promise<GitlabStatus> => {
const s = await fetchJson<GitlabStatus>(Route.GitlabStatus); const s = await fetchJson<GitlabStatus>(Route.GitlabStatus);
setStatus(s); setStatus(s);
return s; return s;
}; };
const loadRepos = async (): Promise<void> => { const loadRepos = async (): Promise<void> => {
const list = await fetchJson<Repo[]>(Route.GitlabRepos); const list = await fetchJson<Repo[]>(Route.GitlabRepos);
setRepos(list); setRepos(list);
}; };
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
try { try {
const s = await loadStatus(); const s = await loadStatus();
if (s.connected) await loadRepos(); if (s.connected) await loadRepos();
} catch (err) { } catch (err) {
setError(errMessage(err)); setError(errMessage(err));
} }
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const connect = async (): Promise<void> => { const connect = async (): Promise<void> => {
if (pat.trim().length === 0) { if (pat.trim().length === 0) {
setError("token required"); setError("token required");
return; return;
} }
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
await fetchJson(Route.GitlabConnect, { method: "POST", body: JSON.stringify({ token: pat.trim() }) }); await fetchJson(Route.GitlabConnect, {
setPat(""); method: "POST",
await loadStatus(); body: JSON.stringify({ token: pat.trim() }),
await loadRepos(); });
} catch (err) { setPat("");
setError(errMessage(err)); await loadStatus();
} finally { await loadRepos();
setBusy(false); } catch (err) {
} setError(errMessage(err));
}; } finally {
setBusy(false);
}
};
const pick = (repo: Repo): void => { const pick = (repo: Repo): void => {
setSelected(repo); setSelected(repo);
setBranch(repo.defaultBranch); 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<void> => { const spawn = async (): Promise<void> => {
if (selected === null) { if (selected === null) {
setError("pick a repo first"); setError("pick a repo first");
return; return;
} }
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const body = { repo: selected.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}) }; const body = {
const res = await fetchJson<SpawnResponse>(Route.Spawn, { method: "POST", body: JSON.stringify(body) }); repo: selected.path,
setSpawning(res); ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
startPolling(res.sessionId); };
} catch (err) { const res = await fetchJson<SpawnResponse>(Route.Spawn, {
setError(errMessage(err)); method: "POST",
} finally { body: JSON.stringify(body),
setBusy(false); });
} setSpawning(res);
}; startPolling(res.sessionId);
} catch (err) {
setError(errMessage(err));
} finally {
setBusy(false);
}
};
const startPolling = (sessionId: string): void => { const startPolling = (sessionId: string): void => {
tickRef.current = 0; tickRef.current = 0;
if (timerRef.current !== null) window.clearInterval(timerRef.current); if (timerRef.current !== null) window.clearInterval(timerRef.current);
timerRef.current = window.setInterval(() => { timerRef.current = window.setInterval(() => {
tickRef.current += 1; tickRef.current += 1;
if (tickRef.current > POLL_MAX_TICKS) { if (tickRef.current > POLL_MAX_TICKS) {
if (timerRef.current !== null) window.clearInterval(timerRef.current); if (timerRef.current !== null) window.clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
return; return;
} }
void (async () => { void (async () => {
try { try {
const list = await fetchJson<SessionListItem[]>(Route.Sessions); const list = await fetchJson<SessionListItem[]>(Route.Sessions);
const s = list.find((x) => x.id === sessionId); const s = list.find((x) => x.id === sessionId);
await store.refresh(); await store.refresh();
if (s !== undefined && s.online) { if (s !== undefined && s.online) {
if (timerRef.current !== null) window.clearInterval(timerRef.current); if (timerRef.current !== null)
timerRef.current = null; window.clearInterval(timerRef.current);
navigate(`/s/${sessionId}`); timerRef.current = null;
} navigate(`/s/${sessionId}`);
} catch (err) { }
pushToast(errMessage(err)); } catch (err) {
} pushToast(errMessage(err));
})(); }
}, POLL_MS); })();
}; }, POLL_MS);
};
const jobLine = (): string => { const jobLine = (): string => {
if (spawning === null) return ""; if (spawning === null) return "";
const job: SpawnJob | undefined = store.spawnJobs.find((j) => j.sessionId === spawning.sessionId); const job: SpawnJob | undefined = store.spawnJobs.find(
if (job !== undefined) return `${job.repo}: ${job.state}`; (j) => j.sessionId === spawning.sessionId,
return "waiting for session to come online…"; );
}; if (job !== undefined) return `${job.repo}: ${job.state}`;
return "waiting for session to come online…";
};
if (status === null) { if (status === null) {
return ( return (
<div className="page"> <div className="page">
<h1>Spawn</h1> <h1>Spawn</h1>
<p className="empty">{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}</p> <p className="empty">
</div> {error.length > 0
); ? `gitlab status failed: ${error}`
} : "checking gitlab…"}
</p>
</div>
);
}
if (!status.connected) { if (!status.connected) {
return ( return (
<div className="page"> <div className="page">
<h1>Spawn</h1> <h1>Spawn</h1>
<form <form
className="spawn-card" className="spawn-card"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void connect(); void connect();
}} }}
> >
<h2>Connect GitLab</h2> <h2>Connect GitLab</h2>
<p className="repo-meta" style={{ margin: "0 0 10px" }}> <p className="repo-meta" style={{ margin: "0 0 10px" }}>
Paste a personal access token (api scope). Stored daemon-side only. Paste a personal access token (api scope). Stored daemon-side only.
</p> </p>
<input <input
type="password" type="password"
value={pat} value={pat}
placeholder="glpat-…" placeholder="glpat-…"
aria-label="GitLab personal access token" aria-label="GitLab personal access token"
onChange={(e) => setPat(e.target.value)} onChange={(e) => setPat(e.target.value)}
/> />
{error.length > 0 && <p className="error-text">{error}</p>} {error.length > 0 && <p className="error-text">{error}</p>}
<div className="actions"> <div className="actions">
<button className="btn-primary" type="submit" disabled={busy}> <button className="btn-primary" type="submit" disabled={busy}>
{busy ? "Connecting…" : "Connect"} {busy ? "Connecting…" : "Connect"}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
); );
} }
return ( return (
<div className="page"> <div className="page">
<h1>Spawn</h1> <h1>Spawn</h1>
{spawning !== null && ( {spawning !== null && (
<div className="spawn-card"> <div className="spawn-card">
<h2>Spawning</h2> <h2>Spawning</h2>
<p className="spawn-job-line">{jobLine()}</p> <p className="spawn-job-line">{jobLine()}</p>
<p className="repo-meta">container {spawning.containerId.slice(0, 12)}</p> <p className="repo-meta">
{error.length > 0 && <p className="error-text">{error}</p>} container {spawning.containerId.slice(0, 12)}
</div> </p>
)} {error.length > 0 && <p className="error-text">{error}</p>}
</div>
)}
{spawning === null && ( {spawning === null && (
<> <>
<div className="spawn-card"> <div className="spawn-card">
<h2>Repository</h2> <h2>Repository</h2>
<input <input
type="text" type="text"
value={query} value={query}
placeholder="filter repos…" placeholder="filter repos…"
aria-label="Filter repositories" aria-label="Filter repositories"
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
{repos === null && <p className="repo-meta">loading repos</p>} {repos === null && <p className="repo-meta">loading repos</p>}
{repos !== null && filtered.length === 0 && <p className="repo-meta">no matching repos</p>} {repos !== null && filtered.length === 0 && (
<div className="repo-list" style={{ marginTop: 10 }}> <p className="repo-meta">no matching repos</p>
{filtered.map((r) => ( )}
<div <div className="repo-list" style={{ marginTop: 10 }}>
key={r.path} {filtered.map((r) => (
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`} <div
role="button" key={r.path}
tabIndex={0} className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
aria-pressed={selected?.path === r.path} role="button"
onClick={() => pick(r)} tabIndex={0}
onKeyDown={(e) => { aria-pressed={selected?.path === r.path}
if (e.key === "Enter" || e.key === " ") pick(r); onClick={() => pick(r)}
}} onKeyDown={(e) => {
> if (e.key === "Enter" || e.key === " ") pick(r);
<div style={{ minWidth: 0 }}> }}
<div className="repo-path">{r.path}</div> >
<div className="repo-meta"> <div style={{ minWidth: 0 }}>
default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)} <div className="repo-path">{r.path}</div>
</div> <div className="repo-meta">
</div> default {r.defaultBranch} ·{" "}
</div> {r.lastActivityAt.slice(0, 10)}
))} </div>
</div> </div>
</div> </div>
))}
</div>
</div>
<div className="spawn-card"> <div className="spawn-card">
<h2>Branch</h2> <h2>Branch</h2>
<div className="row"> <div className="row">
<input <input
type="text" type="text"
value={branch} value={branch}
aria-label="Branch" aria-label="Branch"
placeholder={selected?.defaultBranch ?? "main"} placeholder={selected?.defaultBranch ?? "main"}
disabled={selected === null} disabled={selected === null}
onChange={(e) => setBranch(e.target.value)} onChange={(e) => setBranch(e.target.value)}
/> />
<button <button
type="button" type="button"
className="btn-primary" className="btn-primary"
disabled={selected === null || busy} disabled={selected === null || busy}
aria-label="Spawn container" aria-label="Spawn container"
onClick={() => void spawn()} onClick={() => void spawn()}
> >
{busy ? "Spawning…" : "Spawn"} {busy ? "Spawning…" : "Spawn"}
</button> </button>
</div> </div>
{selected === null && <p className="repo-meta" style={{ marginTop: 8 }}>select a repo above</p>} {selected === null && (
{error.length > 0 && <p className="error-text">{error}</p>} <p className="repo-meta" style={{ marginTop: 8 }}>
</div> select a repo above
</> </p>
)} )}
</div> {error.length > 0 && <p className="error-text">{error}</p>}
); </div>
</>
)}
</div>
);
} }