131 lines
4.9 KiB
JavaScript
131 lines
4.9 KiB
JavaScript
// lvmh worker bridge: headless pi session via SDK; the lvmh plugin (installed
|
|
// 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 { copyFileSync, existsSync } from "node:fs";
|
|
import {
|
|
createAgentSession,
|
|
ModelRuntime,
|
|
SessionManager,
|
|
} 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) => {
|
|
// detached + negative-pid kill: take down the whole process group so
|
|
// setup grandchildren cannot outlive the timeout (node-as-PID1 never
|
|
// reaps adopted orphans).
|
|
const child = spawn("bash", [SETUP_PATH], {
|
|
cwd: "/workspace",
|
|
detached: true,
|
|
stdio: ["ignore", "inherit", "inherit"],
|
|
});
|
|
const timer = setTimeout(() => {
|
|
console.error(
|
|
`[lvmh-bridge] setup timed out after ${SETUP_TIMEOUT_MS}ms, killing process group`,
|
|
);
|
|
try {
|
|
process.kill(-child.pid, "SIGKILL");
|
|
} catch {
|
|
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();
|
|
// Dotfiles-less bakes ship only models.json.fallback: promote it so the
|
|
// registry always resolves the zai provider (glm) instead of "not found".
|
|
const piAgent = "/root/.pi/agent";
|
|
if (!existsSync(`${piAgent}/models.json`) && existsSync(`${piAgent}/models.json.fallback`)) {
|
|
copyFileSync(`${piAgent}/models.json.fallback`, `${piAgent}/models.json`);
|
|
console.error("[lvmh-bridge] promoted models.json.fallback -> models.json");
|
|
}
|
|
// Optional initial model: LVMH_MODEL="provider/model-id" from the spawn
|
|
// request. Resolved against the session's model registry; a bad value is
|
|
// logged and falls back to the default model.
|
|
const requested = process.env.LVMH_MODEL ?? "";
|
|
let model;
|
|
if (requested.includes("/")) {
|
|
const slashIdx = requested.indexOf("/");
|
|
const provider = requested.slice(0, slashIdx);
|
|
const modelId = requested.slice(slashIdx + 1);
|
|
try {
|
|
const runtime = await ModelRuntime.create();
|
|
const models = await runtime.getAvailable(provider);
|
|
const found = models.find((m) => m.id === modelId);
|
|
if (found === undefined) {
|
|
console.error(
|
|
`[lvmh-bridge] LVMH_MODEL ${requested} not found; using default`,
|
|
);
|
|
} else {
|
|
model = found;
|
|
console.error(`[lvmh-bridge] initial model: ${requested}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[lvmh-bridge] model resolution failed:`, err);
|
|
}
|
|
}
|
|
// Fresh session per spawn: without an explicit sessionManager the SDK
|
|
// picks up an existing session file for the cwd — sharing one transcript
|
|
// (and its saved model) across every spawn of the repo. A dedicated dir
|
|
// per daemon session id keeps spawns isolated and LVMH_MODEL authoritative.
|
|
const sessionRoot = process.env.PI_SESSION_DIR ?? "/pi-sessions";
|
|
const sessionDir = `${sessionRoot}/${process.env.LVMH_SESSION_ID ?? "default"}`;
|
|
const sessionManager = SessionManager.create(process.cwd(), sessionDir);
|
|
const { session } = await createAgentSession(
|
|
model === undefined ? { sessionManager } : { model, sessionManager },
|
|
);
|
|
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
|
// bindExtensions the session_start event never fires, so the lvmh plugin
|
|
// would never dial the daemon.
|
|
await session.bindExtensions({ mode: "rpc" });
|
|
console.error(`[lvmh-bridge] session up, cwd=${process.cwd()}`);
|
|
|
|
session.subscribe((event) => {
|
|
// Keep the loop warm; the extension does the mirroring.
|
|
if (event.type === "agent_settled") {
|
|
console.error("[lvmh-bridge] agent settled");
|
|
}
|
|
});
|
|
|
|
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
process.on(sig, () => {
|
|
console.error(`[lvmh-bridge] ${sig}, exiting`);
|
|
process.exit(0);
|
|
});
|
|
}
|
|
// Stay alive forever.
|
|
setInterval(() => {}, 1 << 30);
|
|
} catch (err) {
|
|
console.error("[lvmh-bridge] failed to create agent session", err);
|
|
process.exit(1);
|
|
}
|