feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
const webDir = join(rootDir, "pi-web");
const requiredProfilePaths = [
join(rootDir, "config", "settings.default.json"),
join(rootDir, "config", "models.example.json"),
join(rootDir, "resources", "skills"),
join(rootDir, "resources", "extensions"),
join(rootDir, "resources", "prompts"),
join(rootDir, "resources", "themes"),
];
const packages = [
["@earendil-works/pi-agent-core", "agent"],
["@earendil-works/pi-ai", "ai"],
["@earendil-works/pi-coding-agent", "coding-agent"],
["@earendil-works/pi-tui", "tui"],
];
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
const webPackage = readJson(join(webDir, "package.json"));
const failures = [];
const versions = new Set();
for (const path of requiredProfilePaths) {
if (!existsSync(path)) failures.push(`Missing managed profile path: ${path}`);
}
for (const [packageName, packageDirName] of packages) {
const localDir = resolve(rootDir, "pi", "packages", packageDirName);
const installedDir = resolve(webDir, "node_modules", ...packageName.split("/"));
const localPackagePath = join(localDir, "package.json");
const installedPackagePath = join(installedDir, "package.json");
const expectedSpec = `file:../pi/packages/${packageDirName}`;
if (webPackage.dependencies?.[packageName] !== expectedSpec) {
failures.push(`${packageName} must use ${expectedSpec}`);
continue;
}
if (!existsSync(localPackagePath)) {
failures.push(`Missing local package: ${localPackagePath}`);
continue;
}
if (!existsSync(installedPackagePath)) {
failures.push(`Missing installed package link: ${installedDir}; run npm run setup`);
continue;
}
const localPackage = readJson(localPackagePath);
const installedPackage = readJson(installedPackagePath);
versions.add(localPackage.version);
if (installedPackage.version !== localPackage.version) {
failures.push(`${packageName} installed ${installedPackage.version}, local source is ${localPackage.version}`);
}
const installedRealPath = realpathSync(installedDir);
const mainPath = join(installedDir, installedPackage.main ?? "dist/index.js");
if (!existsSync(mainPath)) {
failures.push(`${packageName} is not built: ${mainPath}`);
}
console.log(
`${packageName}@${localPackage.version} <- ${relative(rootDir, localDir)} ` +
`(installed at ${relative(rootDir, installedRealPath)})`,
);
}
if (versions.size > 1) failures.push(`Pi packages are not lockstep-versioned: ${[...versions].join(", ")}`);
if (failures.length > 0) {
for (const failure of failures) console.error(`ERROR: ${failure}`);
process.exit(1);
}
console.log("Local Pi backend packages are built and installed into Pi Web.");
+3
View File
@@ -0,0 +1,3 @@
import { ensureProfile } from "./profile.mjs";
ensureProfile();
+102
View File
@@ -0,0 +1,102 @@
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { agentDir, ensureProfile, rootDir, skillsHomeDir } from "./profile.mjs";
const args = process.argv.slice(2);
const force = args.includes("--force");
const dryRun = args.includes("--dry-run");
function valueAfter(flag, fallback) {
const index = args.indexOf(flag);
return index >= 0 && args[index + 1] ? resolve(args[index + 1]) : fallback;
}
const sourceAgentDir = valueAfter("--from", join(homedir(), ".pi", "agent"));
const sourceAgentsDir = valueAfter("--skills-from", join(homedir(), ".agents"));
ensureProfile({ quiet: true });
function copyContents(source, destination, label) {
if (!existsSync(source)) {
console.log(`Skip ${label}: source does not exist (${source})`);
return { copied: 0, skipped: 0 };
}
mkdirSync(destination, { recursive: true });
let copied = 0;
let skipped = 0;
for (const entry of readdirSync(source, { withFileTypes: true })) {
const sourcePath = join(source, entry.name);
const destinationPath = join(destination, entry.name);
if (existsSync(destinationPath) && !force) {
skipped++;
console.log(`Skip existing: ${destinationPath}`);
continue;
}
console.log(`${dryRun ? "Would copy" : "Copy"}: ${sourcePath} -> ${destinationPath}`);
if (!dryRun) cpSync(sourcePath, destinationPath, { recursive: true, force });
copied++;
}
return { copied, skipped };
}
function isInsideApp(path) {
const rel = relative(rootDir, resolve(path));
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
}
function isRemotePackageSource(source) {
return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source);
}
function sanitizeMigratedSettings() {
const settingsPath = join(agentDir, "settings.json");
if (!existsSync(settingsPath)) return { changed: false, removed: [] };
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch {
console.log(`Skip settings cleanup: invalid JSON (${settingsPath})`);
return { changed: false, removed: [] };
}
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
console.log(`Skip settings cleanup: expected a JSON object (${settingsPath})`);
return { changed: false, removed: [] };
}
const removed = [];
for (const key of ["extensions", "skills", "prompts", "themes"]) {
if (!Array.isArray(settings[key])) continue;
settings[key] = settings[key].filter((entry) => {
if (typeof entry !== "string" || !isAbsolute(entry) || isInsideApp(entry)) return true;
removed.push(`${key}:${entry}`);
return false;
});
}
if (Array.isArray(settings.packages)) {
settings.packages = settings.packages.filter((entry) => {
const source = typeof entry === "string" ? entry : entry?.source;
if (typeof source !== "string" || isRemotePackageSource(source)) return true;
const resolvedSource = resolve(agentDir, source);
if (isInsideApp(resolvedSource)) return true;
removed.push(`packages:${source}`);
return false;
});
}
if (removed.length > 0) {
console.log(`Remove ${removed.length} external resource reference(s) from migrated settings.`);
for (const entry of removed) console.log(` ${entry}`);
if (!dryRun) writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
}
return { changed: removed.length > 0, removed };
}
const agentResult = copyContents(sourceAgentDir, agentDir, "Pi agent profile");
const skillsResult = copyContents(sourceAgentsDir, join(skillsHomeDir, ".agents"), "global skills profile");
const settingsCleanup = sanitizeMigratedSettings();
console.log(JSON.stringify({ dryRun, force, agent: agentResult, skills: skillsResult, settingsCleanup }, null, 2));
+49
View File
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
test("profile migration copies state and removes external resource references", (t) => {
const root = mkdtempSync(join(tmpdir(), "pi-profile-migration-"));
const sourceAgent = join(root, "source-agent");
const sourceSkills = join(root, "source-agents");
const dataDir = join(root, "managed-data");
const externalExtension = join(root, "external-extension.js");
mkdirSync(join(sourceAgent, "sessions"), { recursive: true });
mkdirSync(join(sourceSkills, "skills", "example"), { recursive: true });
writeFileSync(join(sourceAgent, "sessions", "session.jsonl"), "{}\n");
writeFileSync(join(sourceSkills, "skills", "example", "SKILL.md"), "---\nname: example\n---\n");
writeFileSync(sourceAgent + "-source-marker", "preserved");
writeFileSync(join(sourceAgent, "settings.json"), JSON.stringify({
extensions: [externalExtension, "extensions/local.js"],
packages: ["npm:example@1.0.0", externalExtension],
}));
t.after(() => rmSync(root, { recursive: true, force: true }));
const result = spawnSync(
process.execPath,
[
join(process.cwd(), "scripts", "migrate-profile.mjs"),
"--from",
sourceAgent,
"--skills-from",
sourceSkills,
"--force",
],
{
cwd: process.cwd(),
env: { ...process.env, PI_AGENT_DATA_DIR: dataDir },
encoding: "utf8",
},
);
assert.equal(result.status, 0, result.stderr || result.stdout);
const settings = JSON.parse(readFileSync(join(dataDir, "agent", "settings.json"), "utf8"));
assert.deepEqual(settings.extensions, ["extensions/local.js"]);
assert.deepEqual(settings.packages, ["npm:example@1.0.0"]);
assert.equal(existsSync(join(dataDir, "agent", "sessions", "session.jsonl")), true);
assert.equal(existsSync(join(dataDir, "skills-home", ".agents", "skills", "example", "SKILL.md")), true);
assert.equal(existsSync(sourceAgent + "-source-marker"), true);
});
+67
View File
@@ -0,0 +1,67 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
export const configDir = join(rootDir, "config");
export const resourcesDir = join(rootDir, "resources");
export const dataDir = resolve(process.env.PI_AGENT_DATA_DIR ?? join(rootDir, "data"));
export const agentDir = join(dataDir, "agent");
export const skillsHomeDir = join(dataDir, "skills-home");
export const stateDir = join(dataDir, "state");
const managedDirectories = [
agentDir,
join(agentDir, "sessions"),
join(agentDir, "npm"),
join(agentDir, "git"),
join(agentDir, "bin"),
join(agentDir, "tools"),
join(agentDir, "tmp"),
join(dataDir, "logs"),
join(dataDir, "cache"),
join(skillsHomeDir, ".agents", "skills"),
join(skillsHomeDir, ".pi", "agent", "skills"),
stateDir,
join(resourcesDir, "skills"),
join(resourcesDir, "extensions"),
join(resourcesDir, "prompts"),
join(resourcesDir, "themes"),
];
const seedFiles = [
[join(configDir, "settings.default.json"), join(agentDir, "settings.json")],
[join(configDir, "models.example.json"), join(agentDir, "models.json")],
];
export function ensureProfile({ quiet = false } = {}) {
for (const path of managedDirectories) mkdirSync(path, { recursive: true });
const created = [];
for (const [source, destination] of seedFiles) {
if (existsSync(destination)) continue;
copyFileSync(source, destination);
created.push(destination);
}
if (!quiet) {
console.log(`Managed Pi profile: ${agentDir}`);
if (created.length > 0) console.log(`Initialized ${created.length} profile file(s).`);
}
return { rootDir, configDir, resourcesDir, dataDir, agentDir, skillsHomeDir, stateDir, created };
}
export function managedEnvironment(baseEnv = process.env) {
ensureProfile({ quiet: true });
return {
...baseEnv,
PI_AGENT_MANAGED_RUNTIME: "1",
PI_AGENT_APP_ROOT: rootDir,
PI_AGENT_DATA_DIR: dataDir,
PI_AGENT_RESOURCES_DIR: resourcesDir,
PI_AGENT_SKILLS_HOME: skillsHomeDir,
PI_CODING_AGENT_DIR: agentDir,
XDG_STATE_HOME: stateDir,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
import { managedEnvironment, rootDir } from "./profile.mjs";
const target = process.argv[2] ?? "dev";
const allowedTargets = new Set(["dev", "dev:lan", "start", "start:lan"]);
if (!allowedTargets.has(target)) {
console.error(`Unsupported Pi Web script: ${target}`);
process.exit(2);
}
const npmCliPath = process.env.npm_execpath;
if (!npmCliPath) {
console.error("Run the integrated application through npm.");
process.exit(1);
}
const child = spawn(
process.execPath,
[npmCliPath, "--prefix", join(rootDir, "pi-web"), "run", target],
{
cwd: rootDir,
env: managedEnvironment(),
stdio: "inherit",
},
);
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => child.kill(signal));
}
child.on("error", (error) => {
console.error(error);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 1);
});
+111
View File
@@ -0,0 +1,111 @@
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { ensureProfile } from "./profile.mjs";
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
const npmCliPath = process.env.npm_execpath;
const setupEnv = { ...process.env };
if (process.env.PI_SETUP_USE_PROXY !== "1") {
for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]) {
delete setupEnv[key];
}
}
if (!npmCliPath) {
console.error("Run this setup through npm: npm run setup");
process.exit(1);
}
function run(args, cwd) {
const result = spawnSync(process.execPath, [npmCliPath, ...args], {
cwd,
stdio: "inherit",
env: setupEnv,
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
function hydrateModelData() {
const aiDir = join(rootDir, "pi", "packages", "ai");
const modelDataDir = join(aiDir, "src", "providers", "data");
if (existsSync(join(modelDataDir, "amazon-bedrock.json"))) return;
const aiPackage = JSON.parse(readFileSync(join(aiDir, "package.json"), "utf8"));
const tempDir = mkdtempSync(join(tmpdir(), "pi-ai-model-data-"));
try {
console.log(`Model data is absent; extracting it from ${aiPackage.name}@${aiPackage.version}...`);
const packResult = spawnSync(
process.execPath,
[
npmCliPath,
"pack",
`${aiPackage.name}@${aiPackage.version}`,
"--pack-destination",
tempDir,
"--ignore-scripts",
"--json",
],
{ cwd: rootDir, encoding: "utf8", env: setupEnv },
);
if (packResult.error) throw packResult.error;
if (packResult.status !== 0) {
process.stderr.write(packResult.stderr);
process.exit(packResult.status ?? 1);
}
const packOutput = JSON.parse(packResult.stdout);
const archiveName = packOutput[0]?.filename;
if (!archiveName) throw new Error("npm pack did not return an archive filename");
const extractResult = spawnSync("tar", ["-xzf", join(tempDir, archiveName), "-C", tempDir], {
stdio: "inherit",
});
if (extractResult.error) throw extractResult.error;
if (extractResult.status !== 0) process.exit(extractResult.status ?? 1);
const publishedDataDir = join(tempDir, "package", "dist", "providers", "data");
if (!existsSync(join(publishedDataDir, "amazon-bedrock.json"))) {
throw new Error(`Published model data is missing from ${publishedDataDir}`);
}
cpSync(publishedDataDir, modelDataDir, { recursive: true });
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function assertSupportedNodeVersion() {
const [major, minor] = process.versions.node.split(".").map(Number);
if (major < 22 || (major === 22 && minor < 19)) {
console.error(`Node.js >=22.19.0 is required; current version is ${process.versions.node}.`);
process.exit(1);
}
}
assertSupportedNodeVersion();
ensureProfile();
console.log("[1/4] Installing Pi dependencies...");
run(["ci", "--ignore-scripts"], join(rootDir, "pi"));
console.log("[2/4] Hydrating model data and building local Pi packages...");
hydrateModelData();
run(["run", "build:offline"], join(rootDir, "pi"));
console.log("[3/4] Installing Pi Web with local Pi package links...");
run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "pi-web"));
console.log("[4/4] Verifying the integration...");
const checkResult = spawnSync(process.execPath, [join(rootDir, "scripts", "check-integration.mjs")], {
cwd: rootDir,
stdio: "inherit",
env: setupEnv,
});
if (checkResult.error) throw checkResult.error;
process.exit(checkResult.status ?? 0);
+63
View File
@@ -0,0 +1,63 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { agentDir } from "./profile.mjs";
const baseUrl = process.env.PI_WEB_URL ?? "http://127.0.0.1:30141";
const suffix = randomUUID().replaceAll("-", "");
const managedSkillName = `managed-probe-${suffix}`;
const externalSkillName = `external-probe-${suffix}`;
const managedSkillDir = join(agentDir, "skills", managedSkillName);
const workspace = mkdtempSync(join(tmpdir(), "pi-managed-workspace-"));
const externalSkillDir = join(workspace, ".agents", "skills", externalSkillName);
const externalExtensionDir = join(workspace, ".pi", "extensions");
const externalExtensionMarker = join(workspace, "external-extension-executed");
async function readJson(path, init) {
const response = await fetch(`${baseUrl}${path}`, init);
const data = await response.json();
if (!response.ok) throw new Error(`${path} returned ${response.status}: ${data.error ?? "unknown error"}`);
return data;
}
try {
mkdirSync(managedSkillDir, { recursive: true });
mkdirSync(externalSkillDir, { recursive: true });
mkdirSync(externalExtensionDir, { recursive: true });
writeFileSync(
join(managedSkillDir, "SKILL.md"),
`---\nname: ${managedSkillName}\ndescription: managed isolation probe\n---\nManaged probe.\n`,
);
writeFileSync(
join(externalSkillDir, "SKILL.md"),
`---\nname: ${externalSkillName}\ndescription: external isolation probe\n---\nExternal probe.\n`,
);
writeFileSync(
join(externalExtensionDir, "probe.js"),
`import { writeFileSync } from "node:fs";\nexport default () => { writeFileSync(${JSON.stringify(externalExtensionMarker)}, "executed"); };\n`,
);
await readJson("/api/cwd/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd: workspace }),
});
const result = await readJson(`/api/skills?cwd=${encodeURIComponent(workspace)}`);
const names = new Set(result.skills.map((skill) => skill.name));
const managedSkillLoaded = names.has(managedSkillName);
const externalWorkspaceSkillLoaded = names.has(externalSkillName);
const externalWorkspaceExtensionExecuted = existsSync(externalExtensionMarker);
if (!managedSkillLoaded || externalWorkspaceSkillLoaded || externalWorkspaceExtensionExecuted) {
throw new Error("Managed resource isolation check failed");
}
console.log(JSON.stringify({
managedSkillLoaded,
externalWorkspaceSkillLoaded,
externalWorkspaceExtensionExecuted,
}, null, 2));
} finally {
rmSync(managedSkillDir, { recursive: true, force: true });
rmSync(workspace, { recursive: true, force: true });
}
+73
View File
@@ -0,0 +1,73 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
const baseUrl = process.env.PI_WEB_URL ?? "http://127.0.0.1:30141";
const cwd = resolve(process.env.PI_SMOKE_CWD ?? rootDir);
async function readJson(path, init) {
const response = await fetch(`${baseUrl}${path}`, init);
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
if (!response.ok) {
throw new Error(`${init?.method ?? "GET"} ${path} returned ${response.status}: ${data.error ?? text}`);
}
return data;
}
const pageResponse = await fetch(baseUrl);
const pageHtml = await pageResponse.text();
if (!pageResponse.ok || !pageHtml.includes("Pi Web")) {
throw new Error(`Pi Web page check failed with status ${pageResponse.status}`);
}
const jsonHeaders = { "Content-Type": "application/json" };
const validated = await readJson("/api/cwd/validate", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ cwd }),
});
const models = await readJson(`/api/models?cwd=${encodeURIComponent(cwd)}`);
const runtime = await readJson("/api/runtime");
const created = await readJson("/api/agent/new", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ cwd, type: "ensure_session", toolNames: [] }),
});
const state = await readJson(`/api/agent/${encodeURIComponent(created.sessionId)}`);
if (
!validated.success ||
!runtime.managed ||
resolve(runtime.appRoot) !== rootDir ||
resolve(runtime.agentDir) !== resolve(rootDir, "data", "agent") ||
!created.success ||
!created.sessionId ||
!state.running ||
!state.state
) {
throw new Error("Pi AgentSession smoke check returned an incomplete state");
}
console.log(
JSON.stringify(
{
pageStatus: pageResponse.status,
cwdAccepted: validated.success,
managedRuntime: runtime.managed,
agentDir: runtime.agentDir,
modelCount: Array.isArray(models.modelList) ? models.modelList.length : 0,
defaultModelConfigured: Boolean(models.defaultModel),
sessionCreated: created.success,
runtimeRunning: state.running,
llmRequestSent: false,
},
null,
2,
),
);