mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
This adds codemode and MCP support to pi. Codemode (packages/codemode) runs model-written JavaScript in a QuickJS wasm VM inside a worker. From there the script calls pi's tools as async functions, uses a per-session store, and can read the model catalog and run classifiers. The codemode tool in coding-agent is compatible with Codex's exec tool, and codemode.mode works like Codex's tool modes (on or only). Large results are written to a JSON temp file. MCP (packages/mcp) is a standalone client for stdio and streamable HTTP servers, with OAuth. The core only gets general mechanisms; codemode, tool_search and MCP are built-in extensions that use them. Other extensions can replace these built-ins. Tools declare an exposure: direct, model-only, codemode, deferred or hidden. prepareLoadout() lets an orchestrating tool change which tool declarations the model sees. ctx.executeTool() runs nested calls through validation and the tool_call/tool_result hooks. Those calls emit events with parentToolCallId and are recorded as bounded nestedCalls on the parent result, which compaction and HTML export use. Tools can also return structuredContent with an outputSchema, and can report errors with isError instead of throwing. MCP servers come from mcp.json (global, or per project after the project is trusted) or from pi.registerMcpServer(). They are managed with /mcp and pi mcp list|login|logout, and their runtimes load on first use. Closes #10040
246 lines
6.9 KiB
TypeScript
246 lines
6.9 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import lockfile from "proper-lockfile";
|
|
import { CONFIG_DIR_NAME } from "../config.ts";
|
|
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
|
|
import { stripBom } from "../utils/text.ts";
|
|
|
|
export type ProjectTrustDecision = boolean | null;
|
|
|
|
export interface ProjectTrustStoreEntry {
|
|
path: string;
|
|
decision: boolean;
|
|
}
|
|
|
|
export interface ProjectTrustUpdate {
|
|
path: string;
|
|
decision: ProjectTrustDecision;
|
|
}
|
|
|
|
export interface ProjectTrustOption {
|
|
label: string;
|
|
trusted: boolean;
|
|
updates: ProjectTrustUpdate[];
|
|
savedPath?: string;
|
|
}
|
|
|
|
type TrustFile = Record<string, boolean | null | undefined>;
|
|
|
|
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
|
|
"settings.json",
|
|
"mcp.json",
|
|
"extensions",
|
|
"skills",
|
|
"prompts",
|
|
"themes",
|
|
"SYSTEM.md",
|
|
"APPEND_SYSTEM.md",
|
|
] as const;
|
|
|
|
function normalizeCwd(cwd: string): string {
|
|
return canonicalizePath(resolvePath(cwd));
|
|
}
|
|
|
|
function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null {
|
|
let currentDir = normalizeCwd(cwd);
|
|
while (true) {
|
|
const value = data[currentDir];
|
|
if (value === true || value === false) {
|
|
return { path: currentDir, decision: value };
|
|
}
|
|
|
|
const parentDir = dirname(currentDir);
|
|
if (parentDir === currentDir) {
|
|
return null;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
export function getProjectTrustParentPath(cwd: string): string | undefined {
|
|
const trustPath = normalizeCwd(cwd);
|
|
const parentDir = dirname(trustPath);
|
|
return parentDir === trustPath ? undefined : parentDir;
|
|
}
|
|
|
|
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
|
|
const trustPath = normalizeCwd(cwd);
|
|
const trustOptions: ProjectTrustOption[] = [
|
|
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
|
|
];
|
|
const parentPath = getProjectTrustParentPath(cwd);
|
|
if (parentPath !== undefined) {
|
|
trustOptions.push({
|
|
label: `Trust parent folder (${parentPath})`,
|
|
trusted: true,
|
|
updates: [
|
|
{ path: parentPath, decision: true },
|
|
{ path: trustPath, decision: null },
|
|
],
|
|
savedPath: parentPath,
|
|
});
|
|
}
|
|
if (options?.includeSessionOnly) {
|
|
trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] });
|
|
}
|
|
trustOptions.push({
|
|
label: "Do not trust",
|
|
trusted: false,
|
|
updates: [{ path: trustPath, decision: false }],
|
|
savedPath: trustPath,
|
|
});
|
|
if (options?.includeSessionOnly) {
|
|
trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] });
|
|
}
|
|
return trustOptions;
|
|
}
|
|
|
|
function readTrustFile(path: string): TrustFile {
|
|
if (!existsSync(path)) {
|
|
return {};
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(stripBom(readFileSync(path, "utf-8")));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Failed to read trust store ${path}: ${message}`);
|
|
}
|
|
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
throw new Error(`Invalid trust store ${path}: expected an object`);
|
|
}
|
|
|
|
const data: TrustFile = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (value !== true && value !== false && value !== null) {
|
|
throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`);
|
|
}
|
|
data[key] = value;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function writeTrustFile(path: string, data: TrustFile): void {
|
|
const sorted: TrustFile = {};
|
|
for (const key of Object.keys(data).sort()) {
|
|
const value = data[key];
|
|
if (value === true || value === false || value === null) {
|
|
sorted[key] = value;
|
|
}
|
|
}
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8");
|
|
}
|
|
|
|
function acquireTrustLockSync(path: string): () => void {
|
|
const trustDir = dirname(path);
|
|
mkdirSync(trustDir, { recursive: true });
|
|
const maxAttempts = 10;
|
|
const delayMs = 20;
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
try {
|
|
return lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${path}.lock` });
|
|
} catch (error) {
|
|
const code =
|
|
typeof error === "object" && error !== null && "code" in error
|
|
? String((error as { code?: unknown }).code)
|
|
: undefined;
|
|
if (code !== "ELOCKED" || attempt === maxAttempts) {
|
|
throw error;
|
|
}
|
|
lastError = error;
|
|
const start = Date.now();
|
|
while (Date.now() - start < delayMs) {
|
|
// Sleep synchronously to avoid changing trust store callers to async.
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastError instanceof Error) {
|
|
throw lastError;
|
|
}
|
|
throw new Error("Failed to acquire trust store lock");
|
|
}
|
|
|
|
function withTrustFileLock<T>(path: string, fn: () => T): T {
|
|
const release = acquireTrustLockSync(path);
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true when cwd has project-local resources that must be gated by
|
|
* project trust: trust-requiring entries under cwd/.pi, or .agents/skills in
|
|
* cwd or one of its ancestors. Returns false when no such project resources
|
|
* exist. The user/global ~/.agents/skills directory is always treated as a
|
|
* trusted user resource and is ignored here, even when cwd is $HOME.
|
|
*/
|
|
export function hasTrustRequiringProjectResources(cwd: string): boolean {
|
|
const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir()));
|
|
const userAgentsSkillsDir = join(homeDir, ".agents", "skills");
|
|
let currentDir = canonicalizePath(resolvePath(cwd));
|
|
|
|
const configDir = join(currentDir, CONFIG_DIR_NAME);
|
|
if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) {
|
|
return true;
|
|
}
|
|
|
|
while (true) {
|
|
const agentsSkillsDir = join(currentDir, ".agents", "skills");
|
|
if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
|
|
return true;
|
|
}
|
|
|
|
const parentDir = dirname(currentDir);
|
|
if (parentDir === currentDir) {
|
|
return false;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
export class ProjectTrustStore {
|
|
private trustPath: string;
|
|
|
|
constructor(agentDir: string) {
|
|
this.trustPath = join(resolvePath(agentDir), "trust.json");
|
|
}
|
|
|
|
get(cwd: string): ProjectTrustDecision {
|
|
return this.getEntry(cwd)?.decision ?? null;
|
|
}
|
|
|
|
getEntry(cwd: string): ProjectTrustStoreEntry | null {
|
|
return withTrustFileLock(this.trustPath, () => {
|
|
const data = readTrustFile(this.trustPath);
|
|
return findNearestTrustEntry(data, cwd);
|
|
});
|
|
}
|
|
|
|
set(cwd: string, decision: ProjectTrustDecision): void {
|
|
this.setMany([{ path: cwd, decision }]);
|
|
}
|
|
|
|
setMany(decisions: ProjectTrustUpdate[]): void {
|
|
withTrustFileLock(this.trustPath, () => {
|
|
const data = readTrustFile(this.trustPath);
|
|
for (const { path, decision } of decisions) {
|
|
const key = normalizeCwd(path);
|
|
if (decision === null) {
|
|
delete data[key];
|
|
} else {
|
|
data[key] = decision;
|
|
}
|
|
}
|
|
writeTrustFile(this.trustPath, data);
|
|
});
|
|
}
|
|
}
|