mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 11:39:36 +00:00
feat: separate frontend and backend services
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
// In-memory roots that should be browsable in addition to roots derived from
|
||||
// persisted sessions. Stored on globalThis so Next.js hot-reload keeps them.
|
||||
declare global {
|
||||
var __piAllowedRootsCache: { roots: Set<string>; expiresAt: number } | undefined;
|
||||
var __piAdditionalAllowedRoots: Set<string> | undefined;
|
||||
}
|
||||
|
||||
export function normalizeSlashes(filePath: string): string {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export function getAdditionalAllowedRoots(): Set<string> {
|
||||
if (!globalThis.__piAdditionalAllowedRoots) {
|
||||
globalThis.__piAdditionalAllowedRoots = new Set();
|
||||
}
|
||||
return globalThis.__piAdditionalAllowedRoots;
|
||||
}
|
||||
|
||||
export function allowFileRoot(root: string): void {
|
||||
if (!root) return;
|
||||
const normalizedRoot = normalizeSlashes(root);
|
||||
getAdditionalAllowedRoots().add(normalizedRoot);
|
||||
globalThis.__piAllowedRootsCache?.roots.add(normalizedRoot);
|
||||
}
|
||||
+17
-1
@@ -1,4 +1,20 @@
|
||||
import type { ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
||||
// Structural copy of pi-coding-agent's ResourceDiagnostic/ResourceCollision so
|
||||
// the frontend no longer depends on the pi packages (the backend owns them).
|
||||
export interface ResourceCollision {
|
||||
resourceType: "extension" | "skill" | "prompt" | "theme";
|
||||
name: string;
|
||||
winnerPath: string;
|
||||
loserPath: string;
|
||||
winnerSource?: string;
|
||||
loserSource?: string;
|
||||
}
|
||||
|
||||
export interface ResourceDiagnostic {
|
||||
type: "warning" | "error" | "collision";
|
||||
message: string;
|
||||
path?: string;
|
||||
collision?: ResourceCollision;
|
||||
}
|
||||
|
||||
export interface SkillSearchResult {
|
||||
package: string;
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const {
|
||||
createAppSettingsManager,
|
||||
getAppResourceLoaderOptions,
|
||||
getManagedRuntimePaths,
|
||||
getSkillsCliEnvironment,
|
||||
isManagedPath,
|
||||
} = await jiti.import("./app-runtime.ts");
|
||||
|
||||
async function managedFixture(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-managed-runtime-"));
|
||||
const appRoot = join(root, "app");
|
||||
const dataDir = join(appRoot, "data");
|
||||
const agentDir = join(dataDir, "agent");
|
||||
const resourcesDir = join(appRoot, "resources");
|
||||
const skillsHomeDir = join(dataDir, "skills-home");
|
||||
const stateDir = join(dataDir, "state");
|
||||
const cwd = join(root, "workspace");
|
||||
await Promise.all([
|
||||
mkdir(agentDir, { recursive: true }),
|
||||
mkdir(resourcesDir, { recursive: true }),
|
||||
mkdir(skillsHomeDir, { recursive: true }),
|
||||
mkdir(stateDir, { recursive: true }),
|
||||
mkdir(join(cwd, ".pi"), { recursive: true }),
|
||||
]);
|
||||
|
||||
const previous = new Map();
|
||||
const env = {
|
||||
PI_AGENT_MANAGED_RUNTIME: "1",
|
||||
PI_AGENT_APP_ROOT: appRoot,
|
||||
PI_AGENT_DATA_DIR: dataDir,
|
||||
PI_AGENT_RESOURCES_DIR: resourcesDir,
|
||||
PI_AGENT_SKILLS_HOME: skillsHomeDir,
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
XDG_STATE_HOME: stateDir,
|
||||
};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
previous.set(key, process.env[key]);
|
||||
process.env[key] = value;
|
||||
}
|
||||
t.after(async () => {
|
||||
for (const [key, value] of previous) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
return { root, appRoot, dataDir, agentDir, resourcesDir, skillsHomeDir, stateDir, cwd };
|
||||
}
|
||||
|
||||
test("managed runtime resolves every mutable path inside its application profile", async (t) => {
|
||||
const fixture = await managedFixture(t);
|
||||
const paths = getManagedRuntimePaths();
|
||||
assert.equal(paths.appRoot, fixture.appRoot);
|
||||
assert.equal(paths.agentDir, fixture.agentDir);
|
||||
assert.ok(paths.managedSkillRoots.every((path) => isManagedPath(path)));
|
||||
|
||||
const cliEnv = getSkillsCliEnvironment();
|
||||
assert.equal(cliEnv.HOME, fixture.skillsHomeDir);
|
||||
assert.equal(cliEnv.USERPROFILE, fixture.skillsHomeDir);
|
||||
assert.equal(cliEnv.XDG_STATE_HOME, fixture.stateDir);
|
||||
assert.equal(cliEnv.npm_config_cache, join(fixture.dataDir, "cache", "npm"));
|
||||
});
|
||||
|
||||
test("managed resource policy removes skills outside the application", async (t) => {
|
||||
const fixture = await managedFixture(t);
|
||||
const appSkill = join(fixture.resourcesDir, "skills", "inside", "SKILL.md");
|
||||
const externalSkill = join(fixture.root, "outside", "SKILL.md");
|
||||
await mkdir(join(appSkill, ".."), { recursive: true });
|
||||
await mkdir(join(externalSkill, ".."), { recursive: true });
|
||||
await writeFile(appSkill, "---\nname: inside\n---\n");
|
||||
await writeFile(externalSkill, "---\nname: outside\n---\n");
|
||||
|
||||
const options = getAppResourceLoaderOptions();
|
||||
assert.ok(options.skillsOverride);
|
||||
const filtered = options.skillsOverride({
|
||||
skills: [
|
||||
{ filePath: appSkill },
|
||||
{ filePath: externalSkill },
|
||||
],
|
||||
diagnostics: [
|
||||
{ type: "warning", message: "inside", path: appSkill },
|
||||
{ type: "warning", message: "outside", path: externalSkill },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(filtered.skills.map((skill) => skill.filePath), [appSkill]);
|
||||
assert.deepEqual(filtered.diagnostics.map((diagnostic) => diagnostic.path), [appSkill]);
|
||||
});
|
||||
|
||||
test("managed settings ignore the opened workspace's .pi settings", async (t) => {
|
||||
const fixture = await managedFixture(t);
|
||||
await writeFile(join(fixture.agentDir, "settings.json"), JSON.stringify({ defaultModel: "app-model" }));
|
||||
await writeFile(join(fixture.cwd, ".pi", "settings.json"), JSON.stringify({ defaultModel: "workspace-model" }));
|
||||
|
||||
const settings = createAppSettingsManager(fixture.cwd, fixture.agentDir);
|
||||
assert.equal(settings.isProjectTrusted(), false);
|
||||
assert.deepEqual(settings.getProjectSettings(), {});
|
||||
assert.equal(settings.getDefaultModel(), "app-model");
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from "path";
|
||||
import {
|
||||
SettingsManager,
|
||||
type ResourceDiagnostic,
|
||||
type Skill,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const MANAGED_RUNTIME_ENV = "PI_AGENT_MANAGED_RUNTIME";
|
||||
|
||||
export interface ManagedRuntimePaths {
|
||||
appRoot: string;
|
||||
dataDir: string;
|
||||
agentDir: string;
|
||||
resourcesDir: string;
|
||||
skillsHomeDir: string;
|
||||
stateDir: string;
|
||||
managedSkillRoots: string[];
|
||||
}
|
||||
|
||||
export interface AppResourceLoaderOptions {
|
||||
additionalExtensionPaths?: string[];
|
||||
additionalSkillPaths?: string[];
|
||||
additionalPromptTemplatePaths?: string[];
|
||||
additionalThemePaths?: string[];
|
||||
skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||
skills: Skill[];
|
||||
diagnostics: ResourceDiagnostic[];
|
||||
};
|
||||
}
|
||||
|
||||
export function isManagedRuntime(): boolean {
|
||||
return process.env[MANAGED_RUNTIME_ENV] === "1";
|
||||
}
|
||||
|
||||
function requiredPath(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required in managed runtime mode`);
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
export function getManagedRuntimePaths(): ManagedRuntimePaths {
|
||||
const appRoot = requiredPath("PI_AGENT_APP_ROOT");
|
||||
const dataDir = requiredPath("PI_AGENT_DATA_DIR");
|
||||
const agentDir = requiredPath("PI_CODING_AGENT_DIR");
|
||||
const resourcesDir = requiredPath("PI_AGENT_RESOURCES_DIR");
|
||||
const skillsHomeDir = requiredPath("PI_AGENT_SKILLS_HOME");
|
||||
const stateDir = resolve(process.env.XDG_STATE_HOME ?? `${dataDir}/state`);
|
||||
return {
|
||||
appRoot,
|
||||
dataDir,
|
||||
agentDir,
|
||||
resourcesDir,
|
||||
skillsHomeDir,
|
||||
stateDir,
|
||||
managedSkillRoots: [
|
||||
resolve(resourcesDir, "skills"),
|
||||
resolve(agentDir, "skills"),
|
||||
resolve(skillsHomeDir, ".agents", "skills"),
|
||||
resolve(skillsHomeDir, ".pi", "agent", "skills"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalPath(path: string): string {
|
||||
const resolved = resolve(path);
|
||||
if (!existsSync(resolved)) {
|
||||
const parent = dirname(resolved);
|
||||
if (parent === resolved) return resolved;
|
||||
return resolve(canonicalPath(parent), basename(resolved));
|
||||
}
|
||||
try {
|
||||
return realpathSync.native(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
export function isManagedPath(path: string): boolean {
|
||||
if (!isManagedRuntime()) return true;
|
||||
const candidate = canonicalPath(path);
|
||||
const { appRoot, dataDir, resourcesDir } = getManagedRuntimePaths();
|
||||
return [appRoot, dataDir, resourcesDir].some((root) => {
|
||||
const rel = relative(canonicalPath(root), candidate);
|
||||
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
||||
});
|
||||
}
|
||||
|
||||
export function createAppSettingsManager(
|
||||
cwd: string,
|
||||
agentDir: string,
|
||||
projectTrusted = true,
|
||||
): SettingsManager {
|
||||
return SettingsManager.create(cwd, agentDir, {
|
||||
projectTrusted: isManagedRuntime() ? false : projectTrusted,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAppResourceLoaderOptions(): AppResourceLoaderOptions {
|
||||
if (!isManagedRuntime()) return {};
|
||||
|
||||
const { resourcesDir, managedSkillRoots } = getManagedRuntimePaths();
|
||||
return {
|
||||
additionalExtensionPaths: [resolve(resourcesDir, "extensions")],
|
||||
additionalSkillPaths: managedSkillRoots,
|
||||
additionalPromptTemplatePaths: [resolve(resourcesDir, "prompts")],
|
||||
additionalThemePaths: [resolve(resourcesDir, "themes")],
|
||||
skillsOverride: ({ skills, diagnostics }) => ({
|
||||
skills: skills.filter((skill) => isManagedPath(skill.filePath)),
|
||||
diagnostics: diagnostics.filter((diagnostic) => !diagnostic.path || isManagedPath(diagnostic.path)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSkillsCliEnvironment(): NodeJS.ProcessEnv {
|
||||
if (!isManagedRuntime()) return { ...process.env, FORCE_COLOR: "0" };
|
||||
|
||||
const { dataDir, skillsHomeDir, stateDir } = getManagedRuntimePaths();
|
||||
return {
|
||||
...process.env,
|
||||
HOME: skillsHomeDir,
|
||||
USERPROFILE: skillsHomeDir,
|
||||
XDG_STATE_HOME: stateDir,
|
||||
npm_config_cache: resolve(dataDir, "cache", "npm"),
|
||||
FORCE_COLOR: "0",
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./bash-output.ts");
|
||||
}
|
||||
|
||||
test("accepts only pi bash logs directly inside the configured temp directory", async () => {
|
||||
const { resolveBashOutputPath } = await loadSubject();
|
||||
const tempRoot = join(tmpdir(), "pi-web-output-tests");
|
||||
const expected = resolve(tempRoot, "pi-bash-ab12.log");
|
||||
|
||||
assert.equal(resolveBashOutputPath(join(tempRoot, "pi-bash-ab12.log"), tempRoot), expected);
|
||||
assert.equal(resolveBashOutputPath(join(tempRoot, "..", "pi-bash-ab12.log"), tempRoot), null);
|
||||
assert.equal(resolveBashOutputPath(join(tempRoot, "pi-bash-ab12.log.bak"), tempRoot), null);
|
||||
assert.equal(resolveBashOutputPath(join(`${tempRoot}-other`, "pi-bash-ab12.log"), tempRoot), null);
|
||||
});
|
||||
|
||||
test("reads small output and rejects oversized inline output before buffering it", async () => {
|
||||
const { readUtf8FileWithinLimit } = await loadSubject();
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-bash-output-"));
|
||||
const filePath = join(dir, "pi-bash-ab12.log");
|
||||
try {
|
||||
await writeFile(filePath, "shell output", "utf8");
|
||||
|
||||
assert.deepEqual(await readUtf8FileWithinLimit(filePath, 32), {
|
||||
tooLarge: false,
|
||||
content: "shell output",
|
||||
size: 12,
|
||||
});
|
||||
assert.deepEqual(await readUtf8FileWithinLimit(filePath, 4), {
|
||||
tooLarge: true,
|
||||
size: 12,
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects symbolic links when opening bash output", async (t) => {
|
||||
const { readUtf8FileWithinLimit } = await loadSubject();
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-bash-output-link-"));
|
||||
const targetPath = join(dir, "target.log");
|
||||
const linkPath = join(dir, "pi-bash-link.log");
|
||||
try {
|
||||
await writeFile(targetPath, "not authorized through a link", "utf8");
|
||||
try {
|
||||
await symlink(targetPath, linkPath);
|
||||
} catch (error) {
|
||||
if (error?.code === "EPERM") {
|
||||
t.skip("Creating symbolic links requires additional privileges on this platform");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await assert.rejects(() => readUtf8FileWithinLimit(linkPath));
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open } from "node:fs/promises";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
|
||||
export const MAX_INLINE_BASH_OUTPUT_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export function resolveBashOutputPath(filePath: string, tempRoot: string): string | null {
|
||||
const resolvedPath = resolve(filePath);
|
||||
if (dirname(resolvedPath) !== resolve(tempRoot)) return null;
|
||||
if (!/^pi-bash-[A-Za-z0-9_-]+\.log$/.test(basename(resolvedPath))) return null;
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
export async function openRegularFileNoFollow(filePath: string) {
|
||||
const pathInfo = await lstat(filePath);
|
||||
if (!pathInfo.isFile()) throw new Error("Bash output path is not a regular file");
|
||||
|
||||
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
||||
const handle = await open(filePath, constants.O_RDONLY | noFollow);
|
||||
try {
|
||||
const fileInfo = await handle.stat();
|
||||
if (!fileInfo.isFile()) throw new Error("Bash output path is not a regular file");
|
||||
return { handle, fileInfo };
|
||||
} catch (error) {
|
||||
await handle.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readUtf8FileWithinLimit(
|
||||
filePath: string,
|
||||
maxBytes = MAX_INLINE_BASH_OUTPUT_BYTES,
|
||||
): Promise<{ tooLarge: true; size: number } | { tooLarge: false; content: string; size: number }> {
|
||||
const { handle, fileInfo } = await openRegularFileNoFollow(filePath);
|
||||
try {
|
||||
if (fileInfo.size > maxBytes) return { tooLarge: true, size: fileInfo.size };
|
||||
|
||||
const buffer = Buffer.alloc(fileInfo.size);
|
||||
let bytesRead = 0;
|
||||
while (bytesRead < buffer.length) {
|
||||
const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
|
||||
if (result.bytesRead === 0) break;
|
||||
bytesRead += result.bytesRead;
|
||||
}
|
||||
return {
|
||||
tooLarge: false,
|
||||
content: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
size: bytesRead,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./bounded-form-data.ts");
|
||||
}
|
||||
|
||||
function multipartBody(boundary, value) {
|
||||
return `--${boundary}\r\nContent-Disposition: form-data; name="value"\r\n\r\n${value}\r\n--${boundary}--\r\n`;
|
||||
}
|
||||
|
||||
test("rejects a declared oversized request before reading its body", async () => {
|
||||
const { parseFormDataWithinLimit, RequestBodyTooLargeError } = await loadSubject();
|
||||
const request = new Request("http://localhost/upload", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "99", "content-type": "multipart/form-data; boundary=test" },
|
||||
body: multipartBody("test", "small"),
|
||||
});
|
||||
|
||||
await assert.rejects(() => parseFormDataWithinLimit(request, 10), RequestBodyTooLargeError);
|
||||
});
|
||||
|
||||
test("stops a chunked request once its body exceeds the limit", async () => {
|
||||
const { parseFormDataWithinLimit, RequestBodyTooLargeError } = await loadSubject();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("1234"));
|
||||
controller.enqueue(new TextEncoder().encode("5678"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const request = new Request("http://localhost/upload", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=test" },
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
});
|
||||
|
||||
await assert.rejects(() => parseFormDataWithinLimit(request, 6), RequestBodyTooLargeError);
|
||||
});
|
||||
|
||||
test("parses multipart data inside the request limit", async () => {
|
||||
const { parseFormDataWithinLimit } = await loadSubject();
|
||||
const boundary = "test";
|
||||
const body = multipartBody(boundary, "small");
|
||||
const request = new Request("http://localhost/upload", {
|
||||
method: "POST",
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
body,
|
||||
});
|
||||
|
||||
const formData = await parseFormDataWithinLimit(request, body.length + 1);
|
||||
assert.equal(formData.get("value"), "small");
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
export class RequestBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("Request body exceeds the allowed size");
|
||||
}
|
||||
}
|
||||
|
||||
function declaredContentLength(request: Request): number | null {
|
||||
const value = request.headers.get("content-length");
|
||||
if (!value || !/^\d+$/.test(value)) return null;
|
||||
const length = Number(value);
|
||||
return Number.isSafeInteger(length) ? length : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multipart data only after constraining the complete wire body. This
|
||||
* bounds chunked requests too, where Content-Length is unavailable or false.
|
||||
*/
|
||||
export async function parseFormDataWithinLimit(request: Request, maxBytes: number): Promise<FormData> {
|
||||
const declared = declaredContentLength(request);
|
||||
if (declared !== null && declared > maxBytes) {
|
||||
throw new RequestBodyTooLargeError();
|
||||
}
|
||||
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return request.formData();
|
||||
|
||||
const chunks: BlobPart[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (size + value.byteLength > maxBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new RequestBodyTooLargeError();
|
||||
}
|
||||
size += value.byteLength;
|
||||
const chunk = new Uint8Array(value.byteLength);
|
||||
chunk.set(value);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type");
|
||||
const headers = contentType ? { "content-type": contentType } : undefined;
|
||||
return new Response(new Blob(chunks), { headers }).formData();
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./custom-ui-terminal.ts");
|
||||
}
|
||||
|
||||
test("headless custom UI exposes stable terminal dimensions", async () => {
|
||||
const { createHeadlessCustomUiTui, DEFAULT_CUSTOM_UI_COLUMNS, DEFAULT_CUSTOM_UI_ROWS } = await loadSubject();
|
||||
const tui = createHeadlessCustomUiTui(() => {});
|
||||
|
||||
assert.deepEqual(tui.terminal, {
|
||||
columns: DEFAULT_CUSTOM_UI_COLUMNS,
|
||||
rows: DEFAULT_CUSTOM_UI_ROWS,
|
||||
kittyProtocolActive: false,
|
||||
});
|
||||
assert.equal(Object.isFrozen(tui), true);
|
||||
assert.equal(Object.isFrozen(tui.terminal), true);
|
||||
});
|
||||
|
||||
test("headless custom UI supports plugin rendering and render requests", async () => {
|
||||
const { createHeadlessCustomUiTui } = await loadSubject();
|
||||
let renders = 0;
|
||||
const tui = createHeadlessCustomUiTui(() => { renders += 1; }, 80, 24);
|
||||
const pluginComponent = {
|
||||
render: (width) => [`${width}:${tui.terminal.columns}x${tui.terminal.rows}`],
|
||||
};
|
||||
|
||||
assert.deepEqual(pluginComponent.render(80), ["80:80x24"]);
|
||||
tui.requestRender();
|
||||
assert.equal(renders, 1);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
export const DEFAULT_CUSTOM_UI_COLUMNS = 92;
|
||||
export const DEFAULT_CUSTOM_UI_ROWS = 40;
|
||||
|
||||
export interface HeadlessCustomUiTerminal {
|
||||
readonly columns: number;
|
||||
readonly rows: number;
|
||||
readonly kittyProtocolActive: false;
|
||||
}
|
||||
|
||||
export interface HeadlessCustomUiTui {
|
||||
readonly terminal: HeadlessCustomUiTerminal;
|
||||
requestRender(force?: boolean): void;
|
||||
}
|
||||
|
||||
export function createHeadlessCustomUiTui(
|
||||
requestRender: (force?: boolean) => void,
|
||||
columns = DEFAULT_CUSTOM_UI_COLUMNS,
|
||||
rows = DEFAULT_CUSTOM_UI_ROWS,
|
||||
): HeadlessCustomUiTui {
|
||||
const terminal = Object.freeze({
|
||||
columns,
|
||||
rows,
|
||||
kittyProtocolActive: false as const,
|
||||
});
|
||||
|
||||
return Object.freeze({ terminal, requestRender });
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./directory-browser.ts");
|
||||
}
|
||||
|
||||
test("lists directories and directory symlinks without returning files", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "pi-web-browse-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "project"));
|
||||
await writeFile(path.join(root, "notes.txt"), "test", "utf8");
|
||||
await symlink(path.join(root, "project"), path.join(root, "linked-project"));
|
||||
|
||||
const { listDirectories } = await loadSubject();
|
||||
const directories = await listDirectories(root);
|
||||
|
||||
assert.deepEqual(directories.map((entry) => entry.name), ["linked-project", "project"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("expands home-relative paths and rejects missing directories", async () => {
|
||||
const { getBrowseStartDirectory, normalizeDirectory, resolveDirectory } = await loadSubject();
|
||||
assert.equal(getBrowseStartDirectory(), homedir());
|
||||
assert.equal(getBrowseStartDirectory("/project"), "/project");
|
||||
assert.equal(normalizeDirectory("~/project"), path.join(homedir(), "project"));
|
||||
await assert.rejects(resolveDirectory(path.join(tmpdir(), `pi-web-missing-${Date.now()}`)));
|
||||
});
|
||||
|
||||
test("finds parent directories across POSIX and Windows paths", async () => {
|
||||
const { getParentDirectory } = await loadSubject();
|
||||
|
||||
assert.equal(getParentDirectory("/Users/alex/project"), "/Users/alex");
|
||||
assert.equal(getParentDirectory("/"), null);
|
||||
assert.equal(getParentDirectory("C:\\Users\\Alex\\project"), "C:\\Users\\Alex");
|
||||
assert.equal(getParentDirectory("C:\\"), null);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { readdir, realpath, stat } from "fs/promises";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
|
||||
export interface BrowsableDirectory {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function getBrowseStartDirectory(directory?: string): string {
|
||||
return directory || homedir();
|
||||
}
|
||||
|
||||
export function normalizeDirectory(directory: string): string {
|
||||
if (directory === "~") return homedir();
|
||||
if (directory.startsWith("~/")) return path.resolve(homedir(), directory.slice(2));
|
||||
return path.resolve(directory);
|
||||
}
|
||||
|
||||
export function getParentDirectory(directory: string): string | null {
|
||||
const pathApi = /^[a-zA-Z]:[\\/]/.test(directory) || directory.startsWith("\\\\")
|
||||
? path.win32
|
||||
: path;
|
||||
const normalized = pathApi.normalize(directory);
|
||||
const parent = pathApi.dirname(normalized);
|
||||
return parent === normalized ? null : parent;
|
||||
}
|
||||
|
||||
export async function resolveDirectory(directory: string): Promise<string> {
|
||||
return realpath(normalizeDirectory(directory));
|
||||
}
|
||||
|
||||
export async function listDirectories(directory: string): Promise<BrowsableDirectory[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
// 忽略损坏、不可访问或不指向目录的符号链接。
|
||||
const candidates = await Promise.all(entries.map(async (entry) => {
|
||||
if (entry.isDirectory()) {
|
||||
return { name: entry.name, path: path.join(directory, entry.name) };
|
||||
}
|
||||
if (!entry.isSymbolicLink()) return null;
|
||||
|
||||
try {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
const realEntryPath = await realpath(entryPath);
|
||||
const entryStat = await stat(realEntryPath);
|
||||
if (!entryStat.isDirectory()) return null;
|
||||
return { name: entry.name, path: entryPath };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
return candidates
|
||||
.filter((entry): entry is BrowsableDirectory => entry !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./path-security.ts");
|
||||
}
|
||||
|
||||
test("rejects an existing path that escapes an allowed root through a symlink", async (t) => {
|
||||
const { isExistingPathWithinRoots, isPathWithinRoots } = await loadSubject();
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-file-access-"));
|
||||
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
||||
const allowed = path.join(base, "allowed");
|
||||
const outside = path.join(base, "outside");
|
||||
fs.mkdirSync(allowed);
|
||||
fs.mkdirSync(outside);
|
||||
fs.writeFileSync(path.join(outside, "secret.txt"), "secret");
|
||||
const link = path.join(allowed, "link");
|
||||
fs.symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir");
|
||||
const target = path.join(link, "secret.txt");
|
||||
const roots = new Set([allowed]);
|
||||
|
||||
assert.equal(isPathWithinRoots(target, roots), true);
|
||||
assert.equal(isExistingPathWithinRoots(target, roots), false);
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { readdirSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { getAdditionalAllowedRoots, normalizeSlashes } from "./allowed-roots";
|
||||
import { isExistingPathWithinRoots } from "./path-security";
|
||||
import { listAllSessions } from "./session-reader";
|
||||
export { allowFileRoot, normalizeSlashes } from "./allowed-roots";
|
||||
|
||||
// Short-TTL cache for the allowed-roots set. Without this, every file list/read
|
||||
// request re-scans every pi session on disk just to check access. 5s is short
|
||||
// enough that newly-created cwds appear promptly; stored on globalThis so it
|
||||
// survives Next.js hot-reload.
|
||||
declare global {
|
||||
var __piAllowedRootsCache: { roots: Set<string>; expiresAt: number } | undefined;
|
||||
}
|
||||
|
||||
const ALLOWED_ROOTS_TTL_MS = 5_000;
|
||||
const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;
|
||||
|
||||
export function isWindowsAbsolutePath(filePath: string): boolean {
|
||||
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
|
||||
}
|
||||
|
||||
export async function getAllowedFileRoots(): Promise<Set<string>> {
|
||||
const now = Date.now();
|
||||
const cached = globalThis.__piAllowedRootsCache;
|
||||
if (cached && cached.expiresAt > now) return cached.roots;
|
||||
|
||||
const sessions = await listAllSessions();
|
||||
const roots = new Set<string>();
|
||||
for (const s of sessions) {
|
||||
if (s.cwd) roots.add(normalizeSlashes(s.cwd));
|
||||
// The project root (main repo shared by all worktrees) is browsable too —
|
||||
// the project dropdown lists it even when only worktrees have sessions.
|
||||
if (s.projectRoot) roots.add(normalizeSlashes(s.projectRoot));
|
||||
}
|
||||
|
||||
// Also allow ~/pi-cwd-* directories created by the default-cwd endpoint.
|
||||
try {
|
||||
for (const name of readdirSync(homedir())) {
|
||||
if (/^pi-cwd-\d{8}$/.test(name)) {
|
||||
roots.add(normalizeSlashes(path.join(homedir(), name)));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore if home is unreadable
|
||||
}
|
||||
|
||||
for (const root of getAdditionalAllowedRoots()) roots.add(root);
|
||||
|
||||
globalThis.__piAllowedRootsCache = { roots, expiresAt: now + ALLOWED_ROOTS_TTL_MS };
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function isFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
|
||||
for (const root of allowedRoots) {
|
||||
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
|
||||
const resolver = useWindowsRules ? path.win32 : path;
|
||||
const sep = useWindowsRules ? "\\" : path.sep;
|
||||
const normalized = resolver.resolve(target);
|
||||
const normalizedRoot = resolver.resolve(root);
|
||||
const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
|
||||
const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
|
||||
const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
|
||||
if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Authorize an existing path after resolving symbolic links. */
|
||||
export function isExistingFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
|
||||
return isExistingPathWithinRoots(target, allowedRoots);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./file-dirent.ts");
|
||||
}
|
||||
|
||||
test("uses Dirent types for regular files and directories", async () => {
|
||||
const { resolveDirentIsDirectory } = await loadSubject();
|
||||
const file = { isDirectory: () => false, isFile: () => true };
|
||||
const directory = { isDirectory: () => true, isFile: () => false };
|
||||
|
||||
assert.equal(resolveDirentIsDirectory(file, "/unused/file"), false);
|
||||
assert.equal(resolveDirentIsDirectory(directory, "/unused/directory"), true);
|
||||
});
|
||||
|
||||
test("falls back to stat when the Dirent type is unknown", async (t) => {
|
||||
const { resolveDirentIsDirectory } = await loadSubject();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-dirent-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const directoryPath = path.join(root, "directory");
|
||||
fs.mkdirSync(directoryPath);
|
||||
|
||||
const unknown = { isDirectory: () => false, isFile: () => false };
|
||||
assert.equal(resolveDirentIsDirectory(unknown, directoryPath), true);
|
||||
});
|
||||
|
||||
test("follows directory symlinks and skips dangling symlinks", async (t) => {
|
||||
const { resolveDirentIsDirectory } = await loadSubject();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-dirent-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, "target"));
|
||||
try {
|
||||
fs.symlinkSync("target", path.join(root, "directory-link"), "dir");
|
||||
fs.symlinkSync("missing", path.join(root, "dangling-link"), "file");
|
||||
} catch (error) {
|
||||
if (error?.code === "EPERM") {
|
||||
t.skip("Creating symbolic links requires additional privileges on this platform");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const symlink = { isDirectory: () => false, isFile: () => false };
|
||||
assert.equal(
|
||||
resolveDirentIsDirectory(symlink, path.join(root, "directory-link")),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDirentIsDirectory(symlink, path.join(root, "dangling-link")),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import fs from "fs";
|
||||
|
||||
type DirentType = Pick<fs.Dirent, "isDirectory" | "isFile">;
|
||||
|
||||
export function resolveDirentIsDirectory(
|
||||
dirent: DirentType,
|
||||
fullPath: string,
|
||||
): boolean | null {
|
||||
if (dirent.isDirectory()) return true;
|
||||
if (dirent.isFile()) return false;
|
||||
|
||||
try {
|
||||
return fs.statSync(fullPath).isDirectory();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./file-upload.ts");
|
||||
}
|
||||
|
||||
test("validates upload names without accepting paths or duplicates", async () => {
|
||||
const { validateUploadFileNames } = await loadSubject();
|
||||
|
||||
assert.equal(validateUploadFileNames(["one.txt", "two file.md"]), null);
|
||||
assert.match(validateUploadFileNames(["../secret.txt"]), /must not contain a path/);
|
||||
assert.match(validateUploadFileNames(["folder\\secret.txt"]), /must not contain a path/);
|
||||
assert.match(validateUploadFileNames(["same.txt", "same.txt"]), /Duplicate/);
|
||||
assert.match(validateUploadFileNames([]), /No files/);
|
||||
});
|
||||
|
||||
test("finds conflicts and prevents replacing directories", async (t) => {
|
||||
const { inspectUploadTargets } = await loadSubject();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-upload-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
fs.writeFileSync(path.join(root, "file.txt"), "old");
|
||||
fs.mkdirSync(path.join(root, "directory"));
|
||||
|
||||
assert.deepEqual(
|
||||
inspectUploadTargets(root, ["new.txt", "file.txt", "directory"]),
|
||||
{
|
||||
conflicts: ["file.txt", "directory"],
|
||||
nonReplaceable: ["directory"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("prevents replacing symbolic links", async (t) => {
|
||||
const { inspectUploadTargets } = await loadSubject();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-upload-link-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
fs.writeFileSync(path.join(root, "file.txt"), "old");
|
||||
try {
|
||||
fs.symlinkSync("file.txt", path.join(root, "link.txt"));
|
||||
} catch (error) {
|
||||
if (error?.code === "EPERM") {
|
||||
t.skip("Creating symbolic links requires additional privileges on this platform");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
inspectUploadTargets(root, ["link.txt"]),
|
||||
{
|
||||
conflicts: ["link.txt"],
|
||||
nonReplaceable: ["link.txt"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parses only supported conflict strategies", async () => {
|
||||
const { parseUploadConflictStrategy } = await loadSubject();
|
||||
|
||||
assert.equal(parseUploadConflictStrategy(null), "error");
|
||||
assert.equal(parseUploadConflictStrategy("overwrite"), "overwrite");
|
||||
assert.equal(parseUploadConflictStrategy("skip"), "skip");
|
||||
assert.equal(parseUploadConflictStrategy("rename"), null);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const UPLOAD_CONFLICT_STRATEGIES = ["error", "overwrite", "skip"] as const;
|
||||
export type UploadConflictStrategy = typeof UPLOAD_CONFLICT_STRATEGIES[number];
|
||||
|
||||
const UPLOAD_CONFLICT_STRATEGY_SET = new Set<string>(UPLOAD_CONFLICT_STRATEGIES);
|
||||
|
||||
export interface UploadTargetInspection {
|
||||
conflicts: string[];
|
||||
nonReplaceable: string[];
|
||||
}
|
||||
|
||||
export function parseUploadConflictStrategy(value: string | null): UploadConflictStrategy | null {
|
||||
const candidate = value ?? "error";
|
||||
return UPLOAD_CONFLICT_STRATEGY_SET.has(candidate)
|
||||
? candidate as UploadConflictStrategy
|
||||
: null;
|
||||
}
|
||||
|
||||
export function validateUploadFileNames(fileNames: string[]): string | null {
|
||||
if (fileNames.length === 0) return "No files selected";
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const fileName of fileNames) {
|
||||
if (!fileName || fileName === "." || fileName === ".." || fileName.includes("\0")) {
|
||||
return `Invalid file name: ${fileName || "(empty)"}`;
|
||||
}
|
||||
if (fileName.includes("/") || fileName.includes("\\") || path.basename(fileName) !== fileName) {
|
||||
return `File names must not contain a path: ${fileName}`;
|
||||
}
|
||||
if (seen.has(fileName)) return `Duplicate file name in upload: ${fileName}`;
|
||||
seen.add(fileName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inspectUploadTargets(directory: string, fileNames: string[]): UploadTargetInspection {
|
||||
const conflicts: string[] = [];
|
||||
const nonReplaceable: string[] = [];
|
||||
|
||||
for (const fileName of fileNames) {
|
||||
const destination = path.join(directory, fileName);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(destination);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
|
||||
conflicts.push(fileName);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) nonReplaceable.push(fileName);
|
||||
}
|
||||
|
||||
return { conflicts, nonReplaceable };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./git-status.ts");
|
||||
}
|
||||
|
||||
test("parses null-delimited Git status entries including renames", async () => {
|
||||
const { parseGitPorcelainV1 } = await loadSubject();
|
||||
const entries = parseGitPorcelainV1([
|
||||
" M components/App.tsx",
|
||||
"?? notes.txt",
|
||||
"R src/new-name.ts",
|
||||
"src/old-name.ts",
|
||||
"",
|
||||
].join("\0"));
|
||||
|
||||
assert.deepEqual(entries, [
|
||||
{
|
||||
path: "components/App.tsx",
|
||||
indexStatus: " ",
|
||||
worktreeStatus: "M",
|
||||
},
|
||||
{
|
||||
path: "notes.txt",
|
||||
indexStatus: "?",
|
||||
worktreeStatus: "?",
|
||||
},
|
||||
{
|
||||
path: "src/new-name.ts",
|
||||
originalPath: "src/old-name.ts",
|
||||
indexStatus: "R",
|
||||
worktreeStatus: " ",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("classifies Git status for explorer badges", async () => {
|
||||
const { classifyGitStatus } = await loadSubject();
|
||||
const classify = (pair) => classifyGitStatus({
|
||||
path: "file.ts",
|
||||
indexStatus: pair[0],
|
||||
worktreeStatus: pair[1],
|
||||
});
|
||||
|
||||
assert.deepEqual(classify(" M"), { status: "modified", code: "M" });
|
||||
assert.deepEqual(classify("??"), { status: "untracked", code: "U" });
|
||||
assert.deepEqual(classify("A "), { status: "added", code: "A" });
|
||||
assert.deepEqual(classify("R "), { status: "renamed", code: "R" });
|
||||
assert.deepEqual(classify("UU"), { status: "conflict", code: "C" });
|
||||
assert.deepEqual(classify(" D"), { status: "deleted", code: "D" });
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import { execFile } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
import { TEXT_PREVIEW_MAX_BYTES } from "./file-types";
|
||||
import type {
|
||||
GitFileDiffResponse,
|
||||
GitFileStatus,
|
||||
GitStatusResponse,
|
||||
} from "./git-types";
|
||||
import {
|
||||
classifyGitStatus,
|
||||
parseGitPorcelainV1,
|
||||
type GitPorcelainEntry,
|
||||
} from "./git-status";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const GIT_STATUS_MAX_BUFFER = 8 * 1024 * 1024;
|
||||
|
||||
async function git(cwd: string, args: string[], maxBuffer = GIT_STATUS_MAX_BUFFER): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer,
|
||||
env: { ...process.env, LC_ALL: "C" },
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function findRepositoryRoot(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
return (await git(cwd, ["rev-parse", "--show-toplevel"])).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinPath(parent: string, target: string): boolean {
|
||||
const relative = path.relative(path.resolve(parent), path.resolve(target));
|
||||
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function toGitPath(filePath: string): string {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
async function readStatusEntries(repositoryRoot: string): Promise<GitPorcelainEntry[]> {
|
||||
const output = await git(repositoryRoot, [
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--untracked-files=all",
|
||||
]);
|
||||
return parseGitPorcelainV1(output);
|
||||
}
|
||||
|
||||
async function readTrackedLineStats(
|
||||
repositoryRoot: string,
|
||||
cwd: string,
|
||||
): Promise<{ additions: number; deletions: number }> {
|
||||
const relativeCwd = toGitPath(path.relative(repositoryRoot, cwd));
|
||||
const pathspec = relativeCwd || ".";
|
||||
try {
|
||||
const output = await git(repositoryRoot, [
|
||||
"diff",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--numstat",
|
||||
"HEAD",
|
||||
"--",
|
||||
pathspec,
|
||||
]);
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const [added, deleted] = line.split("\t", 2);
|
||||
const addedCount = Number(added);
|
||||
const deletedCount = Number(deleted);
|
||||
if (Number.isInteger(addedCount)) additions += addedCount;
|
||||
if (Number.isInteger(deletedCount)) deletions += deletedCount;
|
||||
}
|
||||
return { additions, deletions };
|
||||
} catch {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function countUntrackedTextLines(filePath: string): number {
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.size > TEXT_PREVIEW_MAX_BYTES) return 0;
|
||||
const content = fs.readFileSync(filePath);
|
||||
if (hasNullByte(content) || content.length === 0) return 0;
|
||||
const text = content.toString("utf8");
|
||||
return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGitStatus(cwd: string): Promise<GitStatusResponse> {
|
||||
const repositoryRoot = await findRepositoryRoot(cwd);
|
||||
if (!repositoryRoot) {
|
||||
return {
|
||||
isGitRepository: false,
|
||||
repositoryRoot: null,
|
||||
files: [],
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [entries, trackedLineStats] = await Promise.all([
|
||||
readStatusEntries(repositoryRoot),
|
||||
readTrackedLineStats(repositoryRoot, cwd),
|
||||
]);
|
||||
const files = entries.flatMap((entry): GitFileStatus[] => {
|
||||
const filePath = path.resolve(repositoryRoot, entry.path);
|
||||
if (!isWithinPath(cwd, filePath)) return [];
|
||||
const classified = classifyGitStatus(entry);
|
||||
return [{
|
||||
filePath,
|
||||
...classified,
|
||||
indexStatus: entry.indexStatus,
|
||||
worktreeStatus: entry.worktreeStatus,
|
||||
}];
|
||||
});
|
||||
const untrackedAdditions = files.reduce(
|
||||
(total, file) => total + (file.status === "untracked" ? countUntrackedTextLines(file.filePath) : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
isGitRepository: true,
|
||||
repositoryRoot,
|
||||
files,
|
||||
additions: trackedLineStats.additions + untrackedAdditions,
|
||||
deletions: trackedLineStats.deletions,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNullByte(content: Buffer): boolean {
|
||||
return content.includes(0);
|
||||
}
|
||||
|
||||
function createAddedFilePatch(gitPath: string, content: string): string {
|
||||
const hasTrailingNewline = content.endsWith("\n");
|
||||
const lines = content.split("\n");
|
||||
if (hasTrailingNewline) lines.pop();
|
||||
const body = lines.map((line) => `+${line}`).join("\n");
|
||||
const noNewlineMarker = !hasTrailingNewline && lines.length > 0
|
||||
? "\n\\ No newline at end of file"
|
||||
: "";
|
||||
return [
|
||||
`diff --git a/${gitPath} b/${gitPath}`,
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
`+++ b/${gitPath}`,
|
||||
`@@ -0,0 +1,${lines.length} @@`,
|
||||
`${body}${noNewlineMarker}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function createTrackedFilePatch(
|
||||
repositoryRoot: string,
|
||||
relativePath: string,
|
||||
originalPath?: string,
|
||||
): Promise<string | null> {
|
||||
const paths = originalPath && originalPath !== relativePath
|
||||
? [originalPath, relativePath]
|
||||
: [relativePath];
|
||||
try {
|
||||
return await git(repositoryRoot, [
|
||||
"diff",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--unified=3",
|
||||
"HEAD",
|
||||
"--",
|
||||
...paths,
|
||||
], TEXT_PREVIEW_MAX_BYTES * 4);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGitFileDiff(cwd: string, filePath: string): Promise<GitFileDiffResponse> {
|
||||
const repositoryRoot = await findRepositoryRoot(cwd);
|
||||
if (!repositoryRoot || !isWithinPath(repositoryRoot, filePath)) return { supported: false };
|
||||
|
||||
const resolvedFilePath = path.resolve(filePath);
|
||||
const relativePath = toGitPath(path.relative(repositoryRoot, resolvedFilePath));
|
||||
const entries = await readStatusEntries(repositoryRoot);
|
||||
const entry = entries.find((candidate) => candidate.path === relativePath);
|
||||
if (!entry) return { supported: false };
|
||||
|
||||
const { status } = classifyGitStatus(entry);
|
||||
if (status === "deleted") {
|
||||
const patch = await createTrackedFilePatch(repositoryRoot, relativePath, entry.originalPath);
|
||||
if (!patch?.includes("\n@@ ")) return { supported: false };
|
||||
return { supported: true, status, patch };
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(resolvedFilePath);
|
||||
} catch {
|
||||
return { supported: false };
|
||||
}
|
||||
if (!stat.isFile() || stat.size > TEXT_PREVIEW_MAX_BYTES) return { supported: false };
|
||||
|
||||
const currentBuffer = fs.readFileSync(resolvedFilePath);
|
||||
if (hasNullByte(currentBuffer)) return { supported: false };
|
||||
const newContent = currentBuffer.toString("utf8");
|
||||
|
||||
let patch: string;
|
||||
if (status === "untracked") {
|
||||
patch = createAddedFilePatch(relativePath, newContent);
|
||||
} else {
|
||||
const trackedPatch = await createTrackedFilePatch(repositoryRoot, relativePath, entry.originalPath);
|
||||
if (trackedPatch === null) {
|
||||
if (status !== "added") return { supported: false };
|
||||
patch = createAddedFilePatch(relativePath, newContent);
|
||||
} else {
|
||||
patch = trackedPatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (!patch.includes("\n@@ ")) return { supported: false };
|
||||
return { supported: true, status, patch };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { GitFileStatus } from "./git-types";
|
||||
|
||||
export interface GitPorcelainEntry {
|
||||
path: string;
|
||||
originalPath?: string;
|
||||
indexStatus: string;
|
||||
worktreeStatus: string;
|
||||
}
|
||||
|
||||
function usesRenamePath(indexStatus: string, worktreeStatus: string): boolean {
|
||||
return indexStatus === "R" || indexStatus === "C" || worktreeStatus === "R" || worktreeStatus === "C";
|
||||
}
|
||||
|
||||
export function parseGitPorcelainV1(output: string): GitPorcelainEntry[] {
|
||||
const records = output.split("\0");
|
||||
const entries: GitPorcelainEntry[] = [];
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
if (!record || record.length < 4 || record[2] !== " ") continue;
|
||||
const indexStatus = record[0];
|
||||
const worktreeStatus = record[1];
|
||||
const entry: GitPorcelainEntry = {
|
||||
path: record.slice(3),
|
||||
indexStatus,
|
||||
worktreeStatus,
|
||||
};
|
||||
if (usesRenamePath(indexStatus, worktreeStatus)) {
|
||||
entry.originalPath = records[++i] || undefined;
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
const CONFLICT_STATUSES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
||||
|
||||
export function classifyGitStatus(entry: GitPorcelainEntry): Pick<GitFileStatus, "status" | "code"> {
|
||||
const pair = `${entry.indexStatus}${entry.worktreeStatus}`;
|
||||
if (pair === "??") return { status: "untracked", code: "U" };
|
||||
if (CONFLICT_STATUSES.has(pair) || pair.includes("U")) return { status: "conflict", code: "C" };
|
||||
if (pair.includes("D")) return { status: "deleted", code: "D" };
|
||||
if (pair.includes("R") || pair.includes("C")) return { status: "renamed", code: "R" };
|
||||
if (pair.includes("A")) return { status: "added", code: "A" };
|
||||
return { status: "modified", code: "M" };
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import test from "node:test";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
];
|
||||
|
||||
test("configures HTTP_PROXY, HTTPS_PROXY, and NO_PROXY for global fetch", async (t) => {
|
||||
const originalEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of PROXY_ENV_KEYS) delete process.env[key];
|
||||
|
||||
const connectTargets = [];
|
||||
const tunneledRequests = [];
|
||||
const proxy = createServer((req, res) => {
|
||||
res.writeHead(204, { Connection: "close" });
|
||||
res.end();
|
||||
});
|
||||
proxy.on("connect", (req, socket) => {
|
||||
connectTargets.push(req.url);
|
||||
if (req.url?.endsWith(":80")) {
|
||||
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
socket.once("data", (chunk) => {
|
||||
tunneledRequests.push(chunk.toString("utf8").split("\r\n", 1)[0]);
|
||||
socket.end("HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
||||
});
|
||||
return;
|
||||
}
|
||||
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
proxy.listen(0, "127.0.0.1");
|
||||
await once(proxy, "listening");
|
||||
|
||||
t.after(async () => {
|
||||
for (const [key, value] of originalEnv) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
proxy.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
});
|
||||
|
||||
const address = proxy.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const proxyUrl = `http://127.0.0.1:${address.port}`;
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
process.env.HTTPS_PROXY = proxyUrl;
|
||||
process.env.NO_PROXY = "bypass.invalid";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const { configureHttpDispatcher } = await jiti.import("./http-dispatcher.ts");
|
||||
const { getGlobalDispatcher } = await import("undici");
|
||||
|
||||
assert.throws(() => configureHttpDispatcher(-1), /Invalid HTTP idle timeout/);
|
||||
configureHttpDispatcher(2_000);
|
||||
|
||||
const dispatcher = getGlobalDispatcher();
|
||||
configureHttpDispatcher(5_000);
|
||||
assert.equal(getGlobalDispatcher(), dispatcher, "configuration should be idempotent");
|
||||
|
||||
const httpResponse = await fetch("http://target.invalid/through-http-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
assert.equal(httpResponse.status, 204);
|
||||
assert.deepEqual(connectTargets, ["target.invalid:80"]);
|
||||
assert.deepEqual(tunneledRequests, ["GET /through-http-proxy HTTP/1.1"]);
|
||||
|
||||
await assert.rejects(fetch("https://target.invalid/through-https-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}));
|
||||
assert.deepEqual(connectTargets, ["target.invalid:80", "target.invalid:443"]);
|
||||
|
||||
const proxiedRequestCount = connectTargets.length;
|
||||
await assert.rejects(fetch("http://bypass.invalid:9/no-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}));
|
||||
assert.equal(connectTargets.length, proxiedRequestCount);
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as undici from "undici";
|
||||
|
||||
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
|
||||
|
||||
type DispatcherGlobal = typeof globalThis & {
|
||||
__piWebHttpDispatcherConfigured?: boolean;
|
||||
};
|
||||
|
||||
const dispatcherGlobal = globalThis as DispatcherGlobal;
|
||||
const originalGlobalFetch = globalThis.fetch;
|
||||
const ignoreUndiciDispatcherError = (): void => {};
|
||||
|
||||
function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.toLowerCase() === "disabled") return 0;
|
||||
if (trimmed.length === 0) return undefined;
|
||||
return parseHttpIdleTimeoutMs(Number(trimmed));
|
||||
}
|
||||
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
// Undici can emit an internal Client error while terminating a response body.
|
||||
// The body stream still rejects; this prevents the EventEmitter error from
|
||||
// terminating the Next.js process first.
|
||||
function withUndiciErrorListener<T extends undici.Dispatcher>(dispatcher: T): T {
|
||||
if (dispatcher instanceof EventEmitter) {
|
||||
EventEmitter.prototype.on.call(dispatcher, "error", ignoreUndiciDispatcherError);
|
||||
}
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
function createUndiciClient(origin: string | URL, options: object): undici.Dispatcher {
|
||||
return withUndiciErrorListener(
|
||||
new undici.Client(origin, options as undici.Client.Options),
|
||||
);
|
||||
}
|
||||
|
||||
function createUndiciOriginDispatcher(origin: string | URL, options: object): undici.Dispatcher {
|
||||
const dispatcherOptions = options as undici.Pool.Options;
|
||||
if (dispatcherOptions.connections === 1) {
|
||||
return createUndiciClient(origin, dispatcherOptions);
|
||||
}
|
||||
|
||||
return withUndiciErrorListener(
|
||||
new undici.Pool(origin, {
|
||||
...dispatcherOptions,
|
||||
factory: createUndiciClient,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function configureHttpDispatcher(
|
||||
timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS,
|
||||
): void {
|
||||
if (dispatcherGlobal.__piWebHttpDispatcherConfigured) return;
|
||||
|
||||
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
|
||||
if (normalizedTimeoutMs === undefined) {
|
||||
throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);
|
||||
}
|
||||
|
||||
const dispatcher = withUndiciErrorListener(
|
||||
new undici.EnvHttpProxyAgent({
|
||||
allowH2: false,
|
||||
bodyTimeout: normalizedTimeoutMs,
|
||||
headersTimeout: normalizedTimeoutMs,
|
||||
clientFactory: createUndiciClient,
|
||||
factory: createUndiciOriginDispatcher,
|
||||
}),
|
||||
);
|
||||
undici.setGlobalDispatcher(dispatcher);
|
||||
|
||||
// Keep fetch and the dispatcher on the same undici implementation. Preserve
|
||||
// an intentional fetch override installed after this module was loaded.
|
||||
if (globalThis.fetch === originalGlobalFetch) {
|
||||
undici.install?.();
|
||||
}
|
||||
|
||||
dispatcherGlobal.__piWebHttpDispatcherConfigured = true;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface ModelDiscoveryAuth {
|
||||
apiKey?: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringRecord(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
}
|
||||
|
||||
export async function resolveModelDiscoveryAuth(
|
||||
providerName: string,
|
||||
provider: Record<string, unknown>,
|
||||
): Promise<ModelDiscoveryAuth> {
|
||||
let tempDir: string | undefined;
|
||||
try {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-discovery-"));
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
const discoveryModelId = "__pi_web_model_discovery__";
|
||||
writeFileSync(modelsPath, JSON.stringify({
|
||||
providers: {
|
||||
[providerName]: {
|
||||
...provider,
|
||||
models: [{ id: discoveryModelId }],
|
||||
},
|
||||
},
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const modelRuntime = await ModelRuntime.create({ modelsPath });
|
||||
const loadError = modelRuntime.getError();
|
||||
if (loadError) throw new Error(loadError);
|
||||
const model = modelRuntime.getModel(providerName, discoveryModelId);
|
||||
if (!model) throw new Error(`Unable to load provider "${providerName}"`);
|
||||
|
||||
const resolved = await modelRuntime.getAuth(model);
|
||||
if (resolved) {
|
||||
return {
|
||||
apiKey: resolved.auth.apiKey,
|
||||
headers: stringRecord(resolved.auth.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headers: stringRecord(modelRuntime.getCompatibilityRequestConfig(model).headers),
|
||||
};
|
||||
} finally {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { invalidateModelsCache, loadModelsWithCache, withModelRuntimeError } from "./models-cache.ts";
|
||||
|
||||
function modelsData(id) {
|
||||
return {
|
||||
models: { [`provider:${id}`]: id },
|
||||
modelList: [{ id, name: id, provider: "provider" }],
|
||||
defaultModel: null,
|
||||
thinkingLevels: {},
|
||||
thinkingLevelMaps: {},
|
||||
};
|
||||
}
|
||||
|
||||
test("caches model data independently for each cwd", async () => {
|
||||
invalidateModelsCache();
|
||||
let firstLoads = 0;
|
||||
let secondLoads = 0;
|
||||
|
||||
const first = await loadModelsWithCache("/first", async () => {
|
||||
firstLoads += 1;
|
||||
return modelsData("first");
|
||||
});
|
||||
await loadModelsWithCache("/second", async () => {
|
||||
secondLoads += 1;
|
||||
return modelsData("second");
|
||||
});
|
||||
const firstAgain = await loadModelsWithCache("/first", async () => {
|
||||
firstLoads += 1;
|
||||
return modelsData("replacement");
|
||||
});
|
||||
|
||||
assert.deepEqual(firstAgain, first);
|
||||
assert.equal(firstLoads, 1);
|
||||
assert.equal(secondLoads, 1);
|
||||
});
|
||||
|
||||
test("shares one loader between concurrent requests for the same cwd", async () => {
|
||||
invalidateModelsCache();
|
||||
let loads = 0;
|
||||
let finishLoad;
|
||||
const loader = () => {
|
||||
loads += 1;
|
||||
return new Promise((resolve) => { finishLoad = resolve; });
|
||||
};
|
||||
|
||||
const first = loadModelsWithCache("/shared", loader);
|
||||
const second = loadModelsWithCache("/shared", loader);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(loads, 1);
|
||||
finishLoad(modelsData("shared"));
|
||||
assert.deepEqual(await second, await first);
|
||||
});
|
||||
|
||||
test("does not cache a stale load that finishes after invalidation", async () => {
|
||||
invalidateModelsCache();
|
||||
let finishOldLoad;
|
||||
const oldLoad = loadModelsWithCache("/stale", () => new Promise((resolve) => { finishOldLoad = resolve; }));
|
||||
await Promise.resolve();
|
||||
|
||||
invalidateModelsCache();
|
||||
let freshLoads = 0;
|
||||
const fresh = await loadModelsWithCache("/stale", async () => {
|
||||
freshLoads += 1;
|
||||
return modelsData("fresh");
|
||||
});
|
||||
finishOldLoad(modelsData("stale"));
|
||||
await oldLoad;
|
||||
|
||||
const cached = await loadModelsWithCache("/stale", async () => {
|
||||
freshLoads += 1;
|
||||
return modelsData("unexpected");
|
||||
});
|
||||
assert.deepEqual(cached, fresh);
|
||||
assert.equal(freshLoads, 1);
|
||||
});
|
||||
|
||||
test("retries after a model load fails", async () => {
|
||||
invalidateModelsCache();
|
||||
await assert.rejects(
|
||||
loadModelsWithCache("/failed", async () => { throw new Error("load failed"); }),
|
||||
/load failed/,
|
||||
);
|
||||
|
||||
let retries = 0;
|
||||
const fresh = await loadModelsWithCache("/failed", async () => {
|
||||
retries += 1;
|
||||
return modelsData("fresh");
|
||||
});
|
||||
assert.deepEqual(fresh, modelsData("fresh"));
|
||||
assert.equal(retries, 1);
|
||||
});
|
||||
|
||||
test("adds runtime errors without discarding available models", () => {
|
||||
const data = modelsData("builtin");
|
||||
const result = withModelRuntimeError(data, "Invalid models.json schema");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
...data,
|
||||
modelError: "Invalid models.json schema",
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
export interface ModelsData {
|
||||
models: Record<string, string>;
|
||||
modelList: {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
serviceTier?: string;
|
||||
}[];
|
||||
defaultModel: { provider: string; modelId: string } | null;
|
||||
thinkingLevels: Record<string, string[]>;
|
||||
thinkingLevelMaps: Record<string, Record<string, string | null>>;
|
||||
modelError?: string;
|
||||
}
|
||||
|
||||
interface ModelsCacheState {
|
||||
entries: Map<string, { data: ModelsData; expiresAt: number }>;
|
||||
inFlight: Map<string, Promise<ModelsData>>;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piModelsCacheState: ModelsCacheState | undefined;
|
||||
}
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
const MAX_MODELS_CACHE_ENTRIES = 32;
|
||||
|
||||
function getModelsCacheState(): ModelsCacheState {
|
||||
if (!globalThis.__piModelsCacheState) {
|
||||
globalThis.__piModelsCacheState = {
|
||||
entries: new Map(),
|
||||
inFlight: new Map(),
|
||||
generation: 0,
|
||||
};
|
||||
}
|
||||
return globalThis.__piModelsCacheState;
|
||||
}
|
||||
|
||||
export function invalidateModelsCache(): void {
|
||||
const state = getModelsCacheState();
|
||||
state.generation += 1;
|
||||
state.entries.clear();
|
||||
state.inFlight.clear();
|
||||
}
|
||||
|
||||
export function withModelRuntimeError(
|
||||
data: ModelsData,
|
||||
modelError: string | undefined,
|
||||
): ModelsData {
|
||||
return modelError ? { ...data, modelError } : data;
|
||||
}
|
||||
|
||||
export function loadModelsWithCache(
|
||||
cwd: string,
|
||||
loader: () => Promise<ModelsData>,
|
||||
): Promise<ModelsData> {
|
||||
const state = getModelsCacheState();
|
||||
const cached = state.entries.get(cwd);
|
||||
if (cached) {
|
||||
if (cached.expiresAt > Date.now()) return Promise.resolve(cached.data);
|
||||
state.entries.delete(cwd);
|
||||
}
|
||||
|
||||
const existingLoad = state.inFlight.get(cwd);
|
||||
if (existingLoad) return existingLoad;
|
||||
|
||||
const generation = state.generation;
|
||||
const loadPromise: Promise<ModelsData> = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((data) => {
|
||||
if (
|
||||
state.generation === generation &&
|
||||
state.inFlight.get(cwd) === loadPromise
|
||||
) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of state.entries) {
|
||||
if (entry.expiresAt <= now) state.entries.delete(key);
|
||||
}
|
||||
while (state.entries.size >= MAX_MODELS_CACHE_ENTRIES) {
|
||||
const oldestKey = state.entries.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
state.entries.delete(oldestKey);
|
||||
}
|
||||
state.entries.set(cwd, { data, expiresAt: now + MODELS_CACHE_TTL_MS });
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.inFlight.get(cwd) === loadPromise) state.inFlight.delete(cwd);
|
||||
});
|
||||
|
||||
state.inFlight.set(cwd, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { execPath } from "process";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Locate `npx-cli.js` shipped with the running Node.js installation.
|
||||
*
|
||||
* On Windows the `npx` on PATH is actually `npx.cmd`, which Node.js (since
|
||||
* 20.12 due to CVE-2024-27980) refuses to spawn from `execFile`/`spawn`
|
||||
* without `shell: true`. Going through a shell reintroduces quoting bugs for
|
||||
* user-supplied args. Instead we find the real `npx-cli.js` and invoke it
|
||||
* directly via the current `node` binary, which works identically on every
|
||||
* platform and needs no shell.
|
||||
*/
|
||||
function findNpxCli(): string | null {
|
||||
const nodeDir = dirname(execPath);
|
||||
const candidates = [
|
||||
// Windows MSI installer layout: node.exe and node_modules share a dir
|
||||
join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js"),
|
||||
// Unix layout: .../bin/node + .../lib/node_modules/npm/bin/npx-cli.js
|
||||
join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
if (existsSync(p)) return p;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface RunNpxOptions {
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface RunNpxResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-platform wrapper for invoking `npx <args>` without ever using a
|
||||
* shell, so user-controlled arguments are never interpreted as shell syntax.
|
||||
*/
|
||||
export async function runNpx(args: string[], opts: RunNpxOptions = {}): Promise<RunNpxResult> {
|
||||
const npxCli = findNpxCli();
|
||||
const { command, commandArgs } = npxCli
|
||||
? { command: execPath, commandArgs: [npxCli, ...args] }
|
||||
: { command: "npx", commandArgs: args };
|
||||
return execFileAsync(command, commandArgs, {
|
||||
timeout: opts.timeout,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { realpathSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;
|
||||
|
||||
function isWindowsAbsolutePath(filePath: string): boolean {
|
||||
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
|
||||
}
|
||||
|
||||
export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
|
||||
for (const root of roots) {
|
||||
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
|
||||
const resolver = useWindowsRules ? path.win32 : path;
|
||||
const sep = useWindowsRules ? "\\" : path.sep;
|
||||
const normalized = resolver.resolve(target);
|
||||
const normalizedRoot = resolver.resolve(root);
|
||||
const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
|
||||
const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
|
||||
const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
|
||||
if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isExistingPathWithinRoots(target: string, roots: Set<string>): boolean {
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = realpathSync(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const realRoots = new Set<string>();
|
||||
for (const root of roots) {
|
||||
try {
|
||||
realRoots.add(realpathSync(root));
|
||||
} catch {
|
||||
// Ignore stale roots derived from removed sessions or worktrees.
|
||||
}
|
||||
}
|
||||
return isPathWithinRoots(realTarget, realRoots);
|
||||
}
|
||||
+4
-141
@@ -1,10 +1,7 @@
|
||||
import type {
|
||||
AgentSessionEvent,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
SlashCommandInfo,
|
||||
Theme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
// Frontend-only subset of the pi type surface. The full AgentSessionLike
|
||||
// contract (which references pi-coding-agent classes) lives in the backend
|
||||
// (server/src/lib/pi-types.ts); the UI only renders session stats reported
|
||||
// over HTTP, so this file must not import the pi packages.
|
||||
|
||||
export interface ContextUsage {
|
||||
percent: number | null;
|
||||
@@ -12,22 +9,6 @@ export interface ContextUsage {
|
||||
tokens: number | null;
|
||||
}
|
||||
|
||||
export interface ModelLike {
|
||||
id: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NavigateTreeResult {
|
||||
editorText?: string;
|
||||
cancelled: boolean;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionStatsInfo {
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
@@ -47,121 +28,3 @@ export interface SessionStatsInfo {
|
||||
cost: number;
|
||||
contextUsage?: ContextUsage;
|
||||
}
|
||||
|
||||
interface PromptTemplateLike {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}
|
||||
|
||||
interface SkillLike {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}
|
||||
|
||||
interface ResourceLoaderLike {
|
||||
getSkills(): { skills: SkillLike[] };
|
||||
}
|
||||
|
||||
interface ExtensionRunnerLike {
|
||||
getRegisteredCommands(): Array<{
|
||||
invocationName: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}>;
|
||||
setUIContext?(uiContext?: unknown, mode?: "tui" | "rpc" | "json" | "print"): void;
|
||||
}
|
||||
|
||||
type DialogOptionsLike = {
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
type WidgetOptionsLike = {
|
||||
placement?: "aboveEditor" | "belowEditor";
|
||||
};
|
||||
|
||||
export interface ExtensionUiContextLike {
|
||||
select(title: string, options: string[], opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
confirm(title: string, message: string, opts?: DialogOptionsLike): Promise<boolean>;
|
||||
input(title: string, placeholder?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
editor(title: string, prefill?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
onTerminalInput(): () => void;
|
||||
setStatus(key: string, text: string | undefined): void;
|
||||
setWorkingMessage(message?: string): void;
|
||||
setWorkingVisible(visible: boolean): void;
|
||||
setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
|
||||
setHiddenThinkingLabel(label?: string): void;
|
||||
setWidget(key: string, content: string[] | ((...args: never[]) => unknown) | undefined, options?: WidgetOptionsLike): void;
|
||||
setFooter(factory: unknown): void;
|
||||
setHeader(factory: unknown): void;
|
||||
setTitle(title: string): void;
|
||||
custom<T = unknown>(...args: unknown[]): Promise<T>;
|
||||
pasteToEditor(text: string): void;
|
||||
setEditorText(text: string): void;
|
||||
getEditorText(): string;
|
||||
addAutocompleteProvider(): void;
|
||||
setEditorComponent(): void;
|
||||
getEditorComponent(): undefined;
|
||||
readonly theme: Theme;
|
||||
getAllThemes(): unknown[];
|
||||
getTheme(name: string): undefined;
|
||||
setTheme(theme: unknown): { success: boolean; error?: string };
|
||||
getToolsExpanded(): boolean;
|
||||
setToolsExpanded(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export interface AgentSessionLike {
|
||||
readonly sessionId: string;
|
||||
readonly sessionFile: string | undefined;
|
||||
readonly isStreaming: boolean;
|
||||
readonly isCompacting: boolean;
|
||||
readonly autoCompactionEnabled: boolean;
|
||||
readonly autoRetryEnabled: boolean;
|
||||
readonly model: ModelLike | undefined;
|
||||
readonly modelRuntime: {
|
||||
getModel: (provider: string, modelId: string) => ModelLike | undefined;
|
||||
refresh: (options?: { allowNetwork?: boolean }) => Promise<unknown>;
|
||||
};
|
||||
readonly sessionManager: SessionManager;
|
||||
readonly settingsManager: SettingsManager;
|
||||
readonly agent: { state?: { systemPrompt?: string; thinkingLevel?: string } };
|
||||
readonly extensionRunner: ExtensionRunnerLike;
|
||||
readonly promptTemplates: readonly PromptTemplateLike[];
|
||||
readonly resourceLoader: ResourceLoaderLike;
|
||||
|
||||
readonly bindExtensions?: unknown;
|
||||
reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void>;
|
||||
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
||||
prompt(text: string, options?: {
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>;
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
source?: "interactive" | "rpc";
|
||||
}): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode?: number; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string }>;
|
||||
abortBash(): void;
|
||||
readonly isBashRunning: boolean;
|
||||
setModel(model: ModelLike): Promise<void>;
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<NavigateTreeResult>;
|
||||
setThinkingLevel(level: string): void;
|
||||
compact(customInstructions?: string): Promise<unknown>;
|
||||
setSessionName(name: string): void;
|
||||
getSessionStats(): Omit<SessionStatsInfo, "sessionName">;
|
||||
getLastAssistantText(): string | undefined;
|
||||
setAutoCompactionEnabled(enabled: boolean): void;
|
||||
setAutoRetryEnabled(enabled: boolean): void;
|
||||
steer(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
|
||||
followUp(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
|
||||
readonly pendingMessageCount: number;
|
||||
getSteeringMessages(): readonly string[];
|
||||
getFollowUpMessages(): readonly string[];
|
||||
clearQueue(): { steering: string[]; followUp: string[] };
|
||||
getAllTools(): ToolInfo[];
|
||||
getActiveToolNames(): string[];
|
||||
setActiveToolsByName(names: string[]): void;
|
||||
abortCompaction(): void;
|
||||
getContextUsage(): ContextUsage | undefined;
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const {
|
||||
getProjectTrustStatus,
|
||||
projectTrustReloadOptions,
|
||||
trustProject,
|
||||
} = await jiti.import("./project-trust.ts");
|
||||
|
||||
async function createProjectFixture(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-project-trust-"));
|
||||
const cwd = join(root, "project");
|
||||
const agentDir = join(root, "agent");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
return { root, cwd, agentDir };
|
||||
}
|
||||
|
||||
test("clean projects stay on the normal trusted load path", async (t) => {
|
||||
const { cwd, agentDir } = await createProjectFixture(t);
|
||||
|
||||
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
|
||||
requiresTrust: false,
|
||||
trusted: true,
|
||||
});
|
||||
assert.equal(projectTrustReloadOptions(cwd, agentDir), undefined);
|
||||
});
|
||||
|
||||
test("managed runtime never enables project-local resources", async (t) => {
|
||||
const { cwd, agentDir } = await createProjectFixture(t);
|
||||
await mkdir(join(cwd, ".pi", "extensions"), { recursive: true });
|
||||
const previous = process.env.PI_AGENT_MANAGED_RUNTIME;
|
||||
process.env.PI_AGENT_MANAGED_RUNTIME = "1";
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.PI_AGENT_MANAGED_RUNTIME;
|
||||
else process.env.PI_AGENT_MANAGED_RUNTIME = previous;
|
||||
});
|
||||
|
||||
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
|
||||
requiresTrust: false,
|
||||
trusted: false,
|
||||
});
|
||||
assert.deepEqual(trustProject(cwd, agentDir), {
|
||||
requiresTrust: false,
|
||||
trusted: false,
|
||||
});
|
||||
const reloadOptions = projectTrustReloadOptions(cwd, agentDir);
|
||||
assert.ok(reloadOptions);
|
||||
assert.equal(await reloadOptions.resolveProjectTrust(), false);
|
||||
});
|
||||
|
||||
|
||||
test("project extensions execute only after the project is trusted", async (t) => {
|
||||
const { root, cwd, agentDir } = await createProjectFixture(t);
|
||||
const extensionDir = join(cwd, ".pi", "extensions");
|
||||
const marker = join(root, "extension-executed");
|
||||
await mkdir(extensionDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(extensionDir, "probe.js"),
|
||||
`import { writeFileSync } from "node:fs";\nexport default () => { writeFileSync(${JSON.stringify(marker)}, "executed"); };\n`,
|
||||
);
|
||||
|
||||
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
|
||||
requiresTrust: true,
|
||||
trusted: false,
|
||||
});
|
||||
|
||||
const restrictedLoader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await restrictedLoader.reload(projectTrustReloadOptions(cwd, agentDir));
|
||||
assert.equal(existsSync(marker), false);
|
||||
assert.equal(restrictedLoader.getExtensions().extensions.length, 0);
|
||||
|
||||
assert.deepEqual(trustProject(cwd, agentDir), {
|
||||
requiresTrust: true,
|
||||
trusted: true,
|
||||
});
|
||||
|
||||
const trustedLoader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await trustedLoader.reload(projectTrustReloadOptions(cwd, agentDir));
|
||||
assert.equal(existsSync(marker), true);
|
||||
assert.equal(trustedLoader.getExtensions().extensions.length, 1);
|
||||
});
|
||||
|
||||
test("the reload resolver reads the latest persisted trust decision", async (t) => {
|
||||
const { cwd, agentDir } = await createProjectFixture(t);
|
||||
await mkdir(join(cwd, ".pi", "extensions"), { recursive: true });
|
||||
|
||||
const reloadOptions = projectTrustReloadOptions(cwd, agentDir);
|
||||
assert.ok(reloadOptions);
|
||||
assert.equal(await reloadOptions.resolveProjectTrust(), false);
|
||||
|
||||
trustProject(cwd, agentDir);
|
||||
assert.equal(await reloadOptions.resolveProjectTrust(), true);
|
||||
});
|
||||
|
||||
test("all project resource loaders and reloads enforce project trust", async () => {
|
||||
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
||||
const modelsSource = await readFile(new URL("../app/api/models/route.ts", import.meta.url), "utf8");
|
||||
const skillsSource = await readFile(new URL("./skills-service.ts", import.meta.url), "utf8");
|
||||
const skillsInstallSource = await readFile(new URL("../app/api/skills/install/route.ts", import.meta.url), "utf8");
|
||||
const pluginsSource = await readFile(new URL("../app/api/plugins/route.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(rpcSource, /projectTrustReloadOptions\(cwd, agentDir\)/);
|
||||
assert.match(rpcSource, /resourceLoaderReloadOptions: trustReloadOptions/);
|
||||
assert.equal(
|
||||
Array.from(rpcSource.matchAll(/this\.syncProjectTrust\(\);\s*await this\.inner\.reload/g)).length,
|
||||
2,
|
||||
);
|
||||
|
||||
assert.match(modelsSource, /projectTrustReloadOptions\(cwd, agentDir\)/);
|
||||
assert.match(modelsSource, /resourceLoaderReloadOptions: trustReloadOptions/);
|
||||
assert.match(skillsSource, /loader\.reload\(projectTrustReloadOptions\(cwd, agentDir\)\)/);
|
||||
assert.match(pluginsSource, /createAppSettingsManager\(cwd, agentDir, projectTrust\.trusted\)/);
|
||||
assert.match(
|
||||
skillsInstallSource,
|
||||
/getProjectTrustStatus\(cwd, getAgentDir\(\)\)\.trusted/,
|
||||
);
|
||||
assert.equal(
|
||||
Array.from(pluginsSource.matchAll(/createAppSettingsManager\([^\n]+projectTrust\.trusted\)/g)).length,
|
||||
2,
|
||||
);
|
||||
assert.match(pluginsSource, /scope === "project" && !projectTrust\.trusted/);
|
||||
});
|
||||
|
||||
test("the trust API invalidates cached models and restricted runtimes", async () => {
|
||||
const source = await readFile(new URL("../app/api/project-trust/route.ts", import.meta.url), "utf8");
|
||||
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /trustProject\(result\.cwd, agentDir\)/);
|
||||
assert.match(source, /invalidateModelsCache\(\)/);
|
||||
assert.match(source, /destroyRpcSessionsForCwd\(result\.cwd\)/);
|
||||
assert.match(source, /hasBusyRpcSessionForCwd\(result\.cwd\)/);
|
||||
assert.match(rpcSource, /trackStartingSession\(cwd\)/);
|
||||
assert.match(rpcSource, /realpathSync\(resolvedCwd\)/);
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
|
||||
import type { ProjectTrustStatus } from "./api-types";
|
||||
import { isManagedRuntime } from "./app-runtime";
|
||||
|
||||
export function getProjectTrustStatus(cwd: string, agentDir: string): ProjectTrustStatus {
|
||||
if (isManagedRuntime()) return { requiresTrust: false, trusted: false };
|
||||
const requiresTrust = Boolean(cwd) && hasTrustRequiringProjectResources(cwd);
|
||||
if (!requiresTrust) return { requiresTrust: false, trusted: true };
|
||||
|
||||
const trustStore = new ProjectTrustStore(agentDir);
|
||||
return {
|
||||
requiresTrust: true,
|
||||
trusted: trustStore.get(cwd) === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function trustProject(cwd: string, agentDir: string): ProjectTrustStatus {
|
||||
if (isManagedRuntime()) return { requiresTrust: false, trusted: false };
|
||||
const status = getProjectTrustStatus(cwd, agentDir);
|
||||
if (!status.requiresTrust) return status;
|
||||
|
||||
new ProjectTrustStore(agentDir).set(cwd, true);
|
||||
return { requiresTrust: true, trusted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload options that gate project-local, trust-requiring resources — a
|
||||
* repository's `.pi/extensions`, project `.pi/settings.json` extension
|
||||
* entries, and `.agents/skills` — behind the SDK's project-trust store.
|
||||
*
|
||||
* Pi Web *executes* project extensions when it builds session services: their
|
||||
* factory runs on import and their `session_start` handlers run on startup.
|
||||
* Without a trust gate, merely opening an untrusted repository in Pi Web runs
|
||||
* repository-controlled code locally (issue #236). The SDK's resource loader
|
||||
* only imports project extensions once `resolveProjectTrust` resolves true, so
|
||||
* denying trust keeps them dormant.
|
||||
*
|
||||
* Pi Web and the `pi` CLI share the same trust store. Projects with gated
|
||||
* resources default to untrusted until either client records a trust decision.
|
||||
* Returns `undefined` when the project has no trust-requiring resources,
|
||||
* leaving ordinary projects on their existing load path.
|
||||
*/
|
||||
export function projectTrustReloadOptions(
|
||||
cwd: string,
|
||||
agentDir: string,
|
||||
): { resolveProjectTrust: () => Promise<boolean> } | undefined {
|
||||
if (isManagedRuntime()) return { resolveProjectTrust: async () => false };
|
||||
const status = getProjectTrustStatus(cwd, agentDir);
|
||||
if (!status.requiresTrust) return undefined;
|
||||
const trustStore = new ProjectTrustStore(agentDir);
|
||||
return { resolveProjectTrust: async () => trustStore.get(cwd) === true };
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./request-security.ts");
|
||||
}
|
||||
|
||||
test("allows same-origin and non-browser API requests", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "localhost:30141",
|
||||
origin: "http://localhost:30141",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
})), true);
|
||||
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
||||
method: "POST",
|
||||
headers: { host: "localhost:30141" },
|
||||
})), true);
|
||||
});
|
||||
|
||||
test("allows LAN same-origin requests when Next.js uses an internal localhost URL", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
const request = new Request("http://localhost:30141/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "192.168.32.7:30141",
|
||||
origin: "http://192.168.32.7:30141",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
assert.equal(isApiRequestAllowed(request), true);
|
||||
});
|
||||
|
||||
test("allows IPv6 and an explicitly configured hostname", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
const ipv6 = new Request("http://localhost:30141/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "[::1]:30141",
|
||||
origin: "http://[::1]:30141",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
const configured = new Request("http://localhost:30141/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "pi-web.internal:30141",
|
||||
origin: "http://pi-web.internal:30141",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
assert.equal(isApiRequestAllowed(ipv6), true);
|
||||
assert.equal(isApiRequestAllowed(configured, ["pi-web.internal"]), true);
|
||||
});
|
||||
|
||||
test("rejects cross-origin browser API requests", async () => {
|
||||
const { isApiRequestAllowed, shouldCheckApiRequestOrigin } = await loadSubject();
|
||||
const post = new Request("http://localhost:30141/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "localhost:30141",
|
||||
origin: "https://attacker.example",
|
||||
"sec-fetch-site": "cross-site",
|
||||
},
|
||||
});
|
||||
const crossSiteGet = new Request("http://localhost:30141/api/sessions", {
|
||||
headers: { host: "localhost:30141", "sec-fetch-site": "cross-site" },
|
||||
});
|
||||
assert.equal(shouldCheckApiRequestOrigin(post), true);
|
||||
assert.equal(isApiRequestAllowed(post), false);
|
||||
assert.equal(shouldCheckApiRequestOrigin(crossSiteGet), true);
|
||||
assert.equal(isApiRequestAllowed(crossSiteGet), false);
|
||||
});
|
||||
|
||||
test("allows only user-initiated session export document navigations from a PWA", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
const navigationHeaders = {
|
||||
host: "127.0.0.1:30141",
|
||||
"sec-fetch-site": "cross-site",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-user": "?1",
|
||||
};
|
||||
|
||||
assert.equal(isApiRequestAllowed(new Request(
|
||||
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
||||
{ headers: navigationHeaders },
|
||||
)), true);
|
||||
assert.equal(isApiRequestAllowed(new Request(
|
||||
"http://127.0.0.1:30141/api/sessions",
|
||||
{ headers: navigationHeaders },
|
||||
)), false);
|
||||
assert.equal(isApiRequestAllowed(new Request(
|
||||
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
||||
{ headers: { ...navigationHeaders, "sec-fetch-dest": "empty" } },
|
||||
)), false);
|
||||
assert.equal(isApiRequestAllowed(new Request(
|
||||
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
||||
{
|
||||
headers: {
|
||||
...navigationHeaders,
|
||||
"sec-fetch-user": "",
|
||||
},
|
||||
},
|
||||
)), false);
|
||||
assert.equal(isApiRequestAllowed(new Request(
|
||||
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
||||
{ method: "POST", headers: navigationHeaders },
|
||||
)), false);
|
||||
assert.equal(isApiRequestAllowed(new Request(
|
||||
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
||||
{ headers: { ...navigationHeaders, host: "attacker.example:30141" } },
|
||||
)), false);
|
||||
});
|
||||
|
||||
test("rejects an origin that does not match the external request host", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
const request = new Request("http://localhost:30141/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "192.168.32.7:30141",
|
||||
origin: "http://attacker.example",
|
||||
"sec-fetch-site": "same-site",
|
||||
},
|
||||
});
|
||||
assert.equal(isApiRequestAllowed(request), false);
|
||||
});
|
||||
|
||||
test("rejects DNS rebinding even when browser headers say same-origin", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
const request = new Request("http://localhost:30141/api/skills/install", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "attacker.example:30141",
|
||||
origin: "http://attacker.example:30141",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
assert.equal(isApiRequestAllowed(request), false);
|
||||
});
|
||||
|
||||
test("rejects missing, malformed, and unconfigured Host headers", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test")), false);
|
||||
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
||||
headers: { host: "localhost@attacker.example:30141" },
|
||||
})), false);
|
||||
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
||||
headers: { host: "pi-web.internal:30141" },
|
||||
})), false);
|
||||
});
|
||||
|
||||
test("recognizes JSON request content types", async () => {
|
||||
const { hasJsonContentType } = await loadSubject();
|
||||
assert.equal(hasJsonContentType(new Request("http://localhost", {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
})), true);
|
||||
assert.equal(hasJsonContentType(new Request("http://localhost", {
|
||||
headers: { "content-type": "application/problem+json" },
|
||||
})), true);
|
||||
assert.equal(hasJsonContentType(new Request("http://localhost", {
|
||||
headers: { "content-type": "text/plain" },
|
||||
})), false);
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
function normalizeHostname(value: string): string {
|
||||
const unbracketed = value.startsWith("[") && value.endsWith("]")
|
||||
? value.slice(1, -1)
|
||||
: value;
|
||||
return unbracketed.toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function hostnameFromAuthority(value: string): string | null {
|
||||
if (!value || /[\s/@\\]/.test(value)) return null;
|
||||
try {
|
||||
const parsed = new URL(`http://${value}`);
|
||||
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
||||
return null;
|
||||
}
|
||||
return normalizeHostname(parsed.hostname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfiguredHostname(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
return isIP(trimmed) ? normalizeHostname(trimmed) : hostnameFromAuthority(trimmed);
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname.endsWith(".localhost");
|
||||
}
|
||||
|
||||
function configuredHostnamesFromEnvironment(): string[] {
|
||||
return [
|
||||
process.env.PI_WEB_HOSTNAME,
|
||||
...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []),
|
||||
].filter((value): value is string => Boolean(value?.trim()));
|
||||
}
|
||||
|
||||
function canonicalOrigin(value: string): string | null {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestOrigin(request: Request): string | null {
|
||||
const requestUrl = new URL(request.url);
|
||||
const host = request.headers.get("host");
|
||||
return host ? canonicalOrigin(`${requestUrl.protocol}//${host}`) : null;
|
||||
}
|
||||
|
||||
function isUserInitiatedSessionExportNavigation(request: Request): boolean {
|
||||
if (
|
||||
request.method !== "GET"
|
||||
|| request.headers.get("sec-fetch-mode") !== "navigate"
|
||||
|| request.headers.get("sec-fetch-dest") !== "document"
|
||||
|| request.headers.get("sec-fetch-user") !== "?1"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return /^\/api\/sessions\/[^/]+\/export$/.test(new URL(request.url).pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only trust local names, IP literals, or the hostname explicitly selected by
|
||||
* the operator. IP literals preserve LAN access but cannot be DNS-rebound
|
||||
* because the browser keeps the literal address in the Host header.
|
||||
*/
|
||||
export function isApiRequestHostAllowed(
|
||||
request: Request,
|
||||
configuredHostnames = configuredHostnamesFromEnvironment(),
|
||||
): boolean {
|
||||
const host = request.headers.get("host");
|
||||
const hostname = host ? hostnameFromAuthority(host) : null;
|
||||
if (!hostname) return false;
|
||||
if (isLoopbackHostname(hostname) || isIP(hostname)) return true;
|
||||
|
||||
return configuredHostnames.some(
|
||||
(configured) => normalizeConfiguredHostname(configured) === hostname,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reject browser cross-site API requests while preserving non-browser clients. */
|
||||
export function isApiRequestOriginAllowed(request: Request): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
const fetchSite = request.headers.get("sec-fetch-site");
|
||||
if (fetchSite === "cross-site") return false;
|
||||
if (!origin) return true;
|
||||
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
return requestOrigin !== null && canonicalOrigin(origin) === requestOrigin;
|
||||
}
|
||||
|
||||
export function shouldCheckApiRequestOrigin(request: Request): boolean {
|
||||
return request.headers.has("origin") || request.headers.has("sec-fetch-site");
|
||||
}
|
||||
|
||||
export function isApiRequestAllowed(
|
||||
request: Request,
|
||||
configuredHostnames = configuredHostnamesFromEnvironment(),
|
||||
): boolean {
|
||||
if (!isApiRequestHostAllowed(request, configuredHostnames)) return false;
|
||||
if (isUserInitiatedSessionExportNavigation(request)) return true;
|
||||
return !shouldCheckApiRequestOrigin(request) || isApiRequestOriginAllowed(request);
|
||||
}
|
||||
|
||||
export function hasJsonContentType(request: Request): boolean {
|
||||
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
||||
return mediaType === "application/json"
|
||||
|| Boolean(mediaType?.startsWith("application/") && mediaType.endsWith("+json"));
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("RPC session startup preloads extension-registered providers before restoring models", async () => {
|
||||
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
||||
const startupSource = source.slice(source.indexOf("export async function startRpcSession"));
|
||||
|
||||
assert.match(startupSource, /createAgentSessionServices\(/);
|
||||
assert.match(startupSource, /createAgentSessionFromServices\(/);
|
||||
assert.doesNotMatch(startupSource, /await createAgentSession\(/);
|
||||
});
|
||||
|
||||
test("custom extension UI receives the fixed headless terminal facade", async () => {
|
||||
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
||||
const customUiSource = source.slice(
|
||||
source.indexOf("private requestExtensionCustomUi"),
|
||||
source.indexOf("private requestExtensionUi"),
|
||||
);
|
||||
|
||||
assert.match(customUiSource, /createHeadlessCustomUiTui\(/);
|
||||
assert.match(customUiSource, /width,/);
|
||||
});
|
||||
|
||||
test("reloading a session invalidates the models cache", async () => {
|
||||
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
||||
const reloadSource = source.slice(
|
||||
source.indexOf('case "reload"'),
|
||||
source.indexOf('case "abort_compaction"'),
|
||||
);
|
||||
|
||||
assert.match(reloadSource, /await this\.inner\.reload\(\)/);
|
||||
assert.match(reloadSource, /this\.applyForcedEmptySystemPrompt\(\);\s*invalidateModelsCache\(\)/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const extensionPath = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../resources/extensions/searxng-search.ts",
|
||||
);
|
||||
const extensionDir = dirname(extensionPath);
|
||||
|
||||
test("managed SearXNG extension loads and maps the complete search contract", async () => {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-searxng-extension-"));
|
||||
const agentDir = resolve(fixtureRoot, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalToken = process.env.SEARXNG_TOKEN;
|
||||
const originalUrl = process.env.SEARXNG_URL;
|
||||
let capturedRequest;
|
||||
|
||||
process.env.SEARXNG_TOKEN = "test-token";
|
||||
process.env.SEARXNG_URL = "https://search.example.test/search";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
capturedRequest = { input: String(input), init };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
answers: ["A current answer"],
|
||||
results: [
|
||||
{
|
||||
title: "Example result",
|
||||
url: "https://example.test/result",
|
||||
content: "Example snippet",
|
||||
engines: ["google", "github"],
|
||||
publishedDate: "2026-07-30",
|
||||
},
|
||||
],
|
||||
suggestions: ["refined query"],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: fixtureRoot,
|
||||
agentDir,
|
||||
additionalExtensionPaths: [extensionDir],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const loaded = loader.getExtensions();
|
||||
assert.deepEqual(loaded.errors, []);
|
||||
const extension = loaded.extensions.find((candidate) => resolve(candidate.path) === extensionPath);
|
||||
assert.ok(extension, "SearXNG extension should load through the managed extension directory manifest");
|
||||
|
||||
const registeredTool = extension.tools.get("web_search");
|
||||
assert.ok(registeredTool, "web_search should be registered");
|
||||
const tool = registeredTool.definition;
|
||||
|
||||
const result = await tool.execute(
|
||||
"test-call",
|
||||
{
|
||||
query: "pi coding agent",
|
||||
num_results: 5,
|
||||
engines: ["google", "github"],
|
||||
language: "en",
|
||||
page: 2,
|
||||
time_range: "week",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
assert.ok(capturedRequest, "web_search should make an HTTP request");
|
||||
const requestUrl = new URL(capturedRequest.input);
|
||||
assert.equal(requestUrl.searchParams.get("q"), "pi coding agent");
|
||||
assert.equal(requestUrl.searchParams.get("format"), "json");
|
||||
assert.equal(requestUrl.searchParams.get("engines"), "google,github");
|
||||
assert.equal(requestUrl.searchParams.get("language"), "en");
|
||||
assert.equal(requestUrl.searchParams.get("pageno"), "2");
|
||||
assert.equal(requestUrl.searchParams.get("time_range"), "week");
|
||||
assert.equal(new Headers(capturedRequest.init.headers).get("X-Search-Token"), "test-token");
|
||||
assert.match(result.content[0].text, /Example result/);
|
||||
assert.match(result.content[0].text, /https:\/\/example\.test\/result/);
|
||||
assert.equal(result.details.resultCount, 1);
|
||||
assert.equal(JSON.stringify(result).includes("test-token"), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalToken === undefined) delete process.env.SEARXNG_TOKEN;
|
||||
else process.env.SEARXNG_TOKEN = originalToken;
|
||||
if (originalUrl === undefined) delete process.env.SEARXNG_URL;
|
||||
else process.env.SEARXNG_URL = originalUrl;
|
||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { SessionEntry } from "./types";
|
||||
|
||||
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isValidSessionId(sessionId: string | null): sessionId is string {
|
||||
return !!sessionId && SESSION_ID_RE.test(sessionId);
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSlashes(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isPathChar(ch: string): boolean {
|
||||
return /[A-Za-z0-9._~+%@/\\:-]/.test(ch);
|
||||
}
|
||||
|
||||
function hasReferenceBoundaryAfter(text: string, index: number): boolean {
|
||||
if (index >= text.length) return true;
|
||||
const ch = text[index];
|
||||
if (ch === ":") return /\d/.test(text[index + 1] ?? "");
|
||||
return !isPathChar(ch);
|
||||
}
|
||||
|
||||
function containsExactPathReference(text: string, filePath: string): boolean {
|
||||
const target = normalizeSlashes(filePath);
|
||||
const targets = target.startsWith("/") ? [target, `file://${target}`] : [target];
|
||||
const haystacks = new Set([normalizeSlashes(text), normalizeSlashes(safeDecode(text))]);
|
||||
|
||||
for (const haystack of haystacks) {
|
||||
for (const t of targets) {
|
||||
let index = haystack.indexOf(t);
|
||||
while (index !== -1) {
|
||||
const before = index === 0 ? "" : haystack[index - 1];
|
||||
const afterIndex = index + t.length;
|
||||
if ((index === 0 || !isPathChar(before)) && hasReferenceBoundaryAfter(haystack, afterIndex)) {
|
||||
return true;
|
||||
}
|
||||
index = haystack.indexOf(t, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectStrings(value: unknown, out: string[]): void {
|
||||
if (typeof value === "string") {
|
||||
out.push(value);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectStrings(item, out);
|
||||
return;
|
||||
}
|
||||
for (const item of Object.values(value)) collectStrings(item, out);
|
||||
}
|
||||
|
||||
export function isFilePathReferencedByEntries(filePath: string, entries: SessionEntry[]): boolean {
|
||||
for (const entry of entries) {
|
||||
const strings: string[] = [];
|
||||
collectStrings(entry, strings);
|
||||
if (strings.some((text) => containsExactPathReference(text, filePath))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isBashOutputPathReferencedByEntries(filePath: string, entries: SessionEntry[]): boolean {
|
||||
return entries.some((entry) => (
|
||||
entry.type === "message"
|
||||
&& entry.message.role === "bashExecution"
|
||||
&& entry.message.fullOutputPath === filePath
|
||||
));
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./session-file-references-core.ts");
|
||||
}
|
||||
|
||||
test("detects exact external file paths referenced in session entries", async () => {
|
||||
const { isFilePathReferencedByEntries } = await loadSubject();
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "See [/home/me/.codex/config.toml:12](/home/me/.codex/config.toml:12)",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(isFilePathReferencedByEntries("/home/me/.codex/config.toml", entries), true);
|
||||
});
|
||||
|
||||
test("does not authorize sibling files by prefix match", async () => {
|
||||
const { isFilePathReferencedByEntries } = await loadSubject();
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "See /home/me/.codex/config.toml.bak",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(isFilePathReferencedByEntries("/home/me/.codex/config.toml", entries), false);
|
||||
});
|
||||
|
||||
test("authorizes full output only from a bash execution message", async () => {
|
||||
const { isBashOutputPathReferencedByEntries } = await loadSubject();
|
||||
const outputPath = "/tmp/pi-bash-ab12.log";
|
||||
const bashEntry = {
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: {
|
||||
role: "bashExecution",
|
||||
command: "printf test",
|
||||
output: "test",
|
||||
fullOutputPath: outputPath,
|
||||
},
|
||||
};
|
||||
const assistantEntry = {
|
||||
type: "message",
|
||||
id: "entry-2",
|
||||
parentId: "entry-1",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `mentioned ${outputPath}` }],
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isBashOutputPathReferencedByEntries(outputPath, [bashEntry]), true);
|
||||
assert.equal(isBashOutputPathReferencedByEntries(outputPath, [assistantEntry]), false);
|
||||
assert.equal(isBashOutputPathReferencedByEntries("/tmp/pi-bash-other.log", [bashEntry]), false);
|
||||
});
|
||||
|
||||
test("validates session ids before resolving session paths", async () => {
|
||||
const { isValidSessionId } = await loadSubject();
|
||||
|
||||
assert.equal(isValidSessionId("not-a-session-id"), false);
|
||||
assert.equal(isValidSessionId("../../sessions/foo"), false);
|
||||
assert.equal(isValidSessionId("550e8400-e29b-41d4-a716-446655440000"), true);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { getSessionEntries, resolveSessionPath } from "./session-reader";
|
||||
export { isFilePathReferencedByEntries } from "./session-file-references-core";
|
||||
import {
|
||||
isBashOutputPathReferencedByEntries,
|
||||
isFilePathReferencedByEntries,
|
||||
isValidSessionId,
|
||||
} from "./session-file-references-core";
|
||||
|
||||
export async function isFilePathReferencedBySession(filePath: string, sessionId: string | null): Promise<boolean> {
|
||||
if (!isValidSessionId(sessionId)) return false;
|
||||
try {
|
||||
const sessionPath = await resolveSessionPath(sessionId);
|
||||
if (!sessionPath) return false;
|
||||
return isFilePathReferencedByEntries(filePath, getSessionEntries(sessionPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isBashOutputPathReferencedBySession(filePath: string, sessionId: string | null): Promise<boolean> {
|
||||
if (!isValidSessionId(sessionId)) return false;
|
||||
try {
|
||||
const sessionPath = await resolveSessionPath(sessionId);
|
||||
if (!sessionPath) return false;
|
||||
return isBashOutputPathReferencedByEntries(filePath, getSessionEntries(sessionPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
return import("./session-path.ts");
|
||||
}
|
||||
|
||||
test("normalizes Windows separators and casing for session identity", async () => {
|
||||
const { sessionPathKey } = await loadSubject();
|
||||
|
||||
assert.equal(
|
||||
sessionPathKey("C:\\Users\\Alex\\.pi\\sessions\\Parent.jsonl", "win32"),
|
||||
sessionPathKey("c:/Users/Alex/.pi/sessions/parent.jsonl", "win32"),
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves case when session paths are case-sensitive", async () => {
|
||||
const { sessionPathKey } = await loadSubject();
|
||||
|
||||
assert.notEqual(
|
||||
sessionPathKey("/var/lib/pi/Parent.jsonl", "linux"),
|
||||
sessionPathKey("/var/lib/pi/parent.jsonl", "linux"),
|
||||
);
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function sessionPathKey(
|
||||
filePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
const normalized = platform === "win32"
|
||||
? path.win32.normalize(filePath)
|
||||
: path.posix.normalize(filePath);
|
||||
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const { sessionPathKey } = await jiti.import("./session-path.ts");
|
||||
const {
|
||||
buildSessionContext,
|
||||
cacheSessionPath,
|
||||
invalidateSessionPathCache,
|
||||
readSessionHeader,
|
||||
resolveSessionIdByPath,
|
||||
} = await jiti.import("./session-reader.ts");
|
||||
|
||||
function userEntry(id, parentId, content, timestamp = "2026-01-01T00:00:00.000Z") {
|
||||
return {
|
||||
type: "message",
|
||||
id,
|
||||
parentId,
|
||||
timestamp,
|
||||
message: {
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantEntry(id, parentId, text, timestamp = "2026-01-01T00:00:00.000Z") {
|
||||
return {
|
||||
type: "message",
|
||||
id,
|
||||
parentId,
|
||||
timestamp,
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("renders the SDK compaction-aware context with aligned entry IDs", () => {
|
||||
const entries = [
|
||||
userEntry("u1", null, "old user request"),
|
||||
assistantEntry("a1", "u1", "old assistant answer"),
|
||||
userEntry("u2", "a1", "kept user request"),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "cmp",
|
||||
parentId: "u2",
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
summary: "old exchange summary",
|
||||
firstKeptEntryId: "u2",
|
||||
tokensBefore: 123,
|
||||
},
|
||||
userEntry("u3", "cmp", "after compaction"),
|
||||
];
|
||||
|
||||
const context = buildSessionContext(entries);
|
||||
|
||||
assert.deepEqual(context.entryIds, ["cmp", "u2", "u3"]);
|
||||
assert.deepEqual(
|
||||
context.messages.map((message) => [message.role, message.customType, message.content]),
|
||||
[
|
||||
["custom", "compaction", "old exchange summary"],
|
||||
["user", undefined, "kept user request"],
|
||||
["user", undefined, "after compaction"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("uses only the latest compaction on the active path", () => {
|
||||
const entries = [
|
||||
userEntry("u1", null, "old request"),
|
||||
assistantEntry("a1", "u1", "old answer"),
|
||||
userEntry("u2", "a1", "first kept request"),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "cmp1",
|
||||
parentId: "u2",
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
summary: "first summary",
|
||||
firstKeptEntryId: "u2",
|
||||
tokensBefore: 100,
|
||||
},
|
||||
assistantEntry("a2", "cmp1", "second kept answer"),
|
||||
userEntry("u3", "a2", "second kept request"),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "cmp2",
|
||||
parentId: "u3",
|
||||
timestamp: "2026-01-01T00:00:06.000Z",
|
||||
summary: "latest summary",
|
||||
firstKeptEntryId: "a2",
|
||||
tokensBefore: 200,
|
||||
},
|
||||
assistantEntry("a3", "cmp2", "latest answer"),
|
||||
];
|
||||
|
||||
const context = buildSessionContext(entries);
|
||||
|
||||
assert.deepEqual(context.entryIds, ["cmp2", "a2", "u3", "a3"]);
|
||||
assert.equal(context.messages[0].role, "custom");
|
||||
assert.equal(context.messages[0].content, "latest summary");
|
||||
assert.equal(context.messages.length, context.entryIds.length);
|
||||
});
|
||||
|
||||
test("uses the selected leaf's path before a later compaction", () => {
|
||||
const entries = [
|
||||
userEntry("u1", null, "root request"),
|
||||
assistantEntry("a1", "u1", "root answer"),
|
||||
userEntry("u2", "a1", "main branch"),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "cmp",
|
||||
parentId: "u2",
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
summary: "main branch summary",
|
||||
firstKeptEntryId: "u2",
|
||||
tokensBefore: 100,
|
||||
},
|
||||
userEntry("alt", "a1", "alternate branch"),
|
||||
];
|
||||
|
||||
const context = buildSessionContext(entries, "alt");
|
||||
|
||||
assert.deepEqual(context.entryIds, ["u1", "a1", "alt"]);
|
||||
assert.equal(context.messages.some((message) => message.role === "custom"), false);
|
||||
});
|
||||
|
||||
test("returns an empty context for a null leaf", () => {
|
||||
const context = buildSessionContext([
|
||||
userEntry("u1", null, "not active"),
|
||||
], null);
|
||||
|
||||
assert.deepEqual(context.messages, []);
|
||||
assert.deepEqual(context.entryIds, []);
|
||||
});
|
||||
|
||||
test("defers historical thinking without changing live-session content", () => {
|
||||
const entries = [
|
||||
userEntry("u1", null, "start"),
|
||||
{
|
||||
...assistantEntry("a1", "u1", "answer"),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "large reasoning" },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const deferred = buildSessionContext(entries, undefined, { deferThinking: true });
|
||||
assert.deepEqual(deferred.messages[1].content[0], {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
deferred: true,
|
||||
});
|
||||
|
||||
const full = buildSessionContext(entries);
|
||||
assert.equal(full.messages[1].content[0].thinking, "large reasoning");
|
||||
});
|
||||
|
||||
test("does not defer empty historical thinking blocks", () => {
|
||||
const entries = [
|
||||
userEntry("u1", null, "start"),
|
||||
{
|
||||
...assistantEntry("a1", "u1", "answer"),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "" },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const context = buildSessionContext(entries, undefined, { deferThinking: true });
|
||||
assert.deepEqual(context.messages[1].content[0], { type: "thinking", thinking: "" });
|
||||
});
|
||||
|
||||
test("defers only base64 images from historical tool results", () => {
|
||||
const userImage = {
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "QUJDRA==" },
|
||||
};
|
||||
const toolImage = {
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/jpeg", data: "QUJDRA==" },
|
||||
};
|
||||
const toolUrlImage = {
|
||||
type: "image",
|
||||
source: { type: "url", url: "https://example.com/result.png" },
|
||||
};
|
||||
const flatToolImage = {
|
||||
type: "image",
|
||||
data: "QUJDRA==",
|
||||
mimeType: "image/png",
|
||||
};
|
||||
const entries = [
|
||||
userEntry("u1", null, [{ type: "text", text: "inspect this" }, userImage]),
|
||||
assistantEntry("a1", "u1", "reading"),
|
||||
{
|
||||
type: "message",
|
||||
id: "tr1",
|
||||
parentId: "a1",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call1",
|
||||
content: [
|
||||
{ type: "text", text: "Read image file" },
|
||||
toolImage,
|
||||
flatToolImage,
|
||||
toolUrlImage,
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const deferred = buildSessionContext(entries, undefined, { deferToolResultImages: true });
|
||||
assert.deepEqual(deferred.messages[0].content[1], userImage);
|
||||
assert.deepEqual(deferred.messages[2].content[0], { type: "text", text: "Read image file" });
|
||||
assert.deepEqual(deferred.messages[2].content[1], toolUrlImage);
|
||||
assert.match(deferred.messages[2].content[2].text, /2 tool result images omitted.*image\/jpeg, image\/png.*~8 bytes/);
|
||||
|
||||
const full = buildSessionContext(entries);
|
||||
assert.deepEqual(full.messages[2].content[1], toolImage);
|
||||
assert.deepEqual(full.messages[2].content[2], flatToolImage);
|
||||
assert.deepEqual(full.messages[2].content[3], toolUrlImage);
|
||||
});
|
||||
|
||||
test("preserves hidden custom messages so the UI can render them collapsed", () => {
|
||||
const entries = [
|
||||
userEntry("u1", null, "start"),
|
||||
{
|
||||
type: "custom_message",
|
||||
id: "c1",
|
||||
parentId: "u1",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
customType: "extension_debug",
|
||||
content: "hidden extension payload",
|
||||
display: false,
|
||||
details: { source: "test" },
|
||||
},
|
||||
assistantEntry("a1", "c1", "done"),
|
||||
];
|
||||
|
||||
const context = buildSessionContext(entries);
|
||||
|
||||
assert.deepEqual(context.entryIds, ["u1", "c1", "a1"]);
|
||||
assert.equal(context.messages[1].role, "custom");
|
||||
assert.equal(context.messages[1].customType, "extension_debug");
|
||||
assert.equal(context.messages[1].display, false);
|
||||
assert.equal(context.messages[1].content, "hidden extension payload");
|
||||
});
|
||||
|
||||
test("preserves valid epoch timestamps on synthetic UI messages", () => {
|
||||
const entries = [
|
||||
userEntry("u1", null, "start"),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "cmp",
|
||||
parentId: "u1",
|
||||
timestamp: "1970-01-01T00:00:00.000Z",
|
||||
summary: "epoch summary",
|
||||
firstKeptEntryId: "u1",
|
||||
tokensBefore: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const context = buildSessionContext(entries);
|
||||
|
||||
assert.equal(context.messages[0].role, "custom");
|
||||
assert.equal(context.messages[0].customType, "compaction");
|
||||
assert.equal(context.messages[0].timestamp, 0);
|
||||
});
|
||||
|
||||
test("reads only a bounded session header, including headers larger than 4 KiB", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-web-header-"));
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const parentSession = `/tmp/${"p".repeat(5_000)}.jsonl`;
|
||||
writeFileSync(filePath, `${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
parentSession,
|
||||
})}\n${JSON.stringify(userEntry("u1", null, "message"))}\n`);
|
||||
|
||||
try {
|
||||
assert.equal(readSessionHeader(filePath)?.parentSession, parentSession);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for malformed or unbounded session headers", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-web-header-invalid-"));
|
||||
const malformedPath = join(dir, "malformed.jsonl");
|
||||
const oversizedPath = join(dir, "oversized.jsonl");
|
||||
writeFileSync(malformedPath, "{not-json}\n");
|
||||
writeFileSync(oversizedPath, "x".repeat(64 * 1024));
|
||||
|
||||
try {
|
||||
assert.equal(readSessionHeader(malformedPath), null);
|
||||
assert.equal(readSessionHeader(oversizedPath), null);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps forward and reverse session path caches in sync", async () => {
|
||||
const sessionId = "cache-test-session";
|
||||
const filePath = join(tmpdir(), "pi-web-cache-test", "..", "cache-test", "session.jsonl");
|
||||
|
||||
cacheSessionPath(sessionId, filePath);
|
||||
try {
|
||||
assert.equal(
|
||||
await resolveSessionIdByPath(filePath),
|
||||
sessionId,
|
||||
);
|
||||
} finally {
|
||||
invalidateSessionPathCache(sessionId);
|
||||
}
|
||||
|
||||
assert.equal(globalThis.__piSessionPathCache?.has(sessionId), false);
|
||||
assert.equal(globalThis.__piPathToSessionIdCache?.has(sessionPathKey(filePath)), false);
|
||||
});
|
||||
@@ -1,350 +0,0 @@
|
||||
import {
|
||||
SessionManager,
|
||||
buildContextEntries as piBuildContextEntries,
|
||||
buildSessionContext as piBuildSessionContext,
|
||||
getAgentDir,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { closeSync, openSync, readSync } from "fs";
|
||||
import { normalize as normalizePath } from "path";
|
||||
import type { AgentMessage, SessionEntry, SessionHeader, SessionInfo, SessionContext } from "./types";
|
||||
import type { SessionEntry as PiSessionEntry, SessionInfo as PiSessionInfo } from "@earendil-works/pi-coding-agent";
|
||||
import { normalizeToolCalls } from "./normalize";
|
||||
import { sessionPathKey } from "./session-path";
|
||||
import { resolveProject, type ProjectInfo } from "./worktree";
|
||||
|
||||
export { getAgentDir };
|
||||
|
||||
async function loadAllSessions(): Promise<SessionInfo[]> {
|
||||
const piSessions: PiSessionInfo[] = await SessionManager.listAll();
|
||||
const pathToId = new Map<string, string>();
|
||||
for (const s of piSessions) pathToId.set(sessionPathKey(s.path), s.id);
|
||||
|
||||
// Resolve each unique cwd to its project root (main repo shared by all
|
||||
// worktrees). resolveProject caches per-cwd, so this is cheap after warmup.
|
||||
const uniqueCwds = [...new Set(piSessions.map((s) => s.cwd).filter(Boolean))];
|
||||
const projectByCwd = new Map<string, ProjectInfo>();
|
||||
await Promise.all(uniqueCwds.map(async (cwd) => {
|
||||
projectByCwd.set(cwd, await resolveProject(cwd));
|
||||
}));
|
||||
|
||||
return piSessions.map((s) => {
|
||||
cacheSessionPath(s.id, s.path);
|
||||
const project = s.cwd ? projectByCwd.get(s.cwd) : undefined;
|
||||
return {
|
||||
path: s.path,
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
name: s.name,
|
||||
created: s.created instanceof Date ? s.created.toISOString() : String(s.created),
|
||||
modified: s.modified instanceof Date ? s.modified.toISOString() : String(s.modified),
|
||||
messageCount: s.messageCount,
|
||||
firstMessage: s.firstMessage || "(no messages)",
|
||||
parentSessionId: s.parentSessionPath ? pathToId.get(sessionPathKey(s.parentSessionPath)) : undefined,
|
||||
projectRoot: project?.projectRoot ?? s.cwd,
|
||||
...(project?.isWorktree && project.branch ? { worktreeBranch: project.branch } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAllSessions(): Promise<SessionInfo[]> {
|
||||
const generation = globalThis.__piSessionListGeneration ?? 0;
|
||||
|
||||
// Return cached result if still fresh (avoids re-scanning session files
|
||||
// and re-spawning git processes on every page load).
|
||||
if (globalThis.__piSessionListCache && Date.now() - globalThis.__piSessionListCache.ts < SESSION_LIST_CACHE_TTL_MS) {
|
||||
return globalThis.__piSessionListCache.data;
|
||||
}
|
||||
|
||||
// Coalescing dedup: concurrent callers share the same in-flight promise
|
||||
// only while it belongs to the current cache generation.
|
||||
if (globalThis.__piSessionListPromise && globalThis.__piSessionListPromiseGeneration === generation) {
|
||||
return globalThis.__piSessionListPromise;
|
||||
}
|
||||
|
||||
const loadPromise = loadAllSessions().then((data) => {
|
||||
// An invalidation may happen while the scan is in flight. Do not let that
|
||||
// older result repopulate the cache after a session mutation.
|
||||
if ((globalThis.__piSessionListGeneration ?? 0) === generation) {
|
||||
globalThis.__piSessionListCache = { data, ts: Date.now() };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
const trackedPromise = loadPromise.finally(() => {
|
||||
if (globalThis.__piSessionListPromise === trackedPromise) {
|
||||
globalThis.__piSessionListPromise = undefined;
|
||||
globalThis.__piSessionListPromiseGeneration = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
globalThis.__piSessionListPromise = trackedPromise;
|
||||
globalThis.__piSessionListPromiseGeneration = generation;
|
||||
return trackedPromise;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session path caches, stored in globalThis for hot-reload safety.
|
||||
// ============================================================================
|
||||
declare global {
|
||||
var __piSessionPathCache: Map<string, string> | undefined;
|
||||
var __piPathToSessionIdCache: Map<string, string> | undefined;
|
||||
var __piSessionListPromise: Promise<SessionInfo[]> | undefined;
|
||||
var __piSessionListPromiseGeneration: number | undefined;
|
||||
var __piSessionListGeneration: number | undefined;
|
||||
var __piSessionListCache: { data: SessionInfo[]; ts: number } | undefined;
|
||||
}
|
||||
|
||||
const SESSION_LIST_CACHE_TTL_MS = 30_000;
|
||||
|
||||
export function invalidateSessionListCache(): void {
|
||||
globalThis.__piSessionListGeneration = (globalThis.__piSessionListGeneration ?? 0) + 1;
|
||||
globalThis.__piSessionListCache = undefined;
|
||||
}
|
||||
|
||||
function getPathCache(): Map<string, string> {
|
||||
if (!globalThis.__piSessionPathCache) globalThis.__piSessionPathCache = new Map();
|
||||
return globalThis.__piSessionPathCache;
|
||||
}
|
||||
|
||||
function getPathToIdCache(): Map<string, string> {
|
||||
if (!globalThis.__piPathToSessionIdCache) globalThis.__piPathToSessionIdCache = new Map();
|
||||
return globalThis.__piPathToSessionIdCache;
|
||||
}
|
||||
|
||||
export async function resolveSessionPath(sessionId: string): Promise<string | null> {
|
||||
const cached = getPathCache().get(sessionId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Cache miss: scan all sessions to populate cache, then retry
|
||||
await listAllSessions();
|
||||
return getPathCache().get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
export async function resolveSessionIdByPath(filePath: string): Promise<string | undefined> {
|
||||
const pathKey = sessionPathKey(filePath);
|
||||
const cached = getPathToIdCache().get(pathKey);
|
||||
if (cached) return cached;
|
||||
|
||||
await listAllSessions();
|
||||
return getPathToIdCache().get(pathKey);
|
||||
}
|
||||
|
||||
export function cacheSessionPath(sessionId: string, filePath: string): void {
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
const pathKey = sessionPathKey(normalizedPath);
|
||||
const pathCache = getPathCache();
|
||||
const reverseCache = getPathToIdCache();
|
||||
const previousPath = pathCache.get(sessionId);
|
||||
const previousPathKey = previousPath ? sessionPathKey(previousPath) : undefined;
|
||||
const previousSessionId = reverseCache.get(pathKey);
|
||||
const previousOwnerPath = previousSessionId ? pathCache.get(previousSessionId) : undefined;
|
||||
if (previousPathKey && previousPathKey !== pathKey && reverseCache.get(previousPathKey) === sessionId) {
|
||||
reverseCache.delete(previousPathKey);
|
||||
}
|
||||
if (
|
||||
previousSessionId &&
|
||||
previousSessionId !== sessionId &&
|
||||
previousOwnerPath &&
|
||||
sessionPathKey(previousOwnerPath) === pathKey
|
||||
) {
|
||||
pathCache.delete(previousSessionId);
|
||||
}
|
||||
pathCache.set(sessionId, normalizedPath);
|
||||
reverseCache.set(pathKey, sessionId);
|
||||
}
|
||||
|
||||
export function invalidateSessionPathCache(sessionId: string): void {
|
||||
const pathCache = getPathCache();
|
||||
const reverseCache = getPathToIdCache();
|
||||
const filePath = pathCache.get(sessionId);
|
||||
pathCache.delete(sessionId);
|
||||
const pathKey = filePath ? sessionPathKey(filePath) : undefined;
|
||||
if (pathKey && reverseCache.get(pathKey) === sessionId) {
|
||||
reverseCache.delete(pathKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionHeader(filePath: string): SessionHeader | null {
|
||||
const fd = openSync(filePath, "r");
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
const maxHeaderBytes = 64 * 1024;
|
||||
let position = 0;
|
||||
let foundNewline = false;
|
||||
|
||||
while (position < maxHeaderBytes && !foundNewline) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(4096, maxHeaderBytes - position));
|
||||
const bytesRead = readSync(fd, buffer, 0, buffer.length, position);
|
||||
if (bytesRead === 0) break;
|
||||
const data = buffer.subarray(0, bytesRead);
|
||||
const newlineIndex = data.indexOf(0x0a);
|
||||
chunks.push(newlineIndex === -1 ? data : data.subarray(0, newlineIndex));
|
||||
position += bytesRead;
|
||||
foundNewline = newlineIndex !== -1;
|
||||
}
|
||||
|
||||
if (!foundNewline && position >= maxHeaderBytes) return null;
|
||||
const firstLine = Buffer.concat(chunks).toString("utf8").trimEnd();
|
||||
if (!firstLine) return null;
|
||||
try {
|
||||
const header = JSON.parse(firstLine) as SessionHeader;
|
||||
return header.type === "session" ? header : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionEntries(filePath: string): SessionEntry[] {
|
||||
const entries = SessionManager.open(filePath).getEntries();
|
||||
return entries as unknown as SessionEntry[];
|
||||
}
|
||||
|
||||
export function buildSessionContext(
|
||||
entries: SessionEntry[],
|
||||
leafId?: string | null,
|
||||
options: { deferThinking?: boolean; deferToolResultImages?: boolean } = {},
|
||||
): SessionContext {
|
||||
const byId = new Map<string, SessionEntry>();
|
||||
for (const e of entries) byId.set(e.id, e);
|
||||
|
||||
const piEntries = entries as unknown as PiSessionEntry[];
|
||||
const piCtx = piBuildSessionContext(piEntries, leafId, byId as unknown as Map<string, PiSessionEntry>);
|
||||
|
||||
const contextEntries = piBuildContextEntries(
|
||||
piEntries,
|
||||
leafId,
|
||||
byId as unknown as Map<string, PiSessionEntry>,
|
||||
);
|
||||
|
||||
// Convert the SDK-selected context entries and their IDs together. This keeps
|
||||
// fork/navigation targets aligned while preserving pi's compaction ordering.
|
||||
const messages: AgentMessage[] = [];
|
||||
const entryIds: string[] = [];
|
||||
for (const entry of contextEntries) {
|
||||
const localEntry = entry as unknown as SessionEntry;
|
||||
const m = entryToUiMessage(localEntry, options);
|
||||
if (m) {
|
||||
messages.push(m);
|
||||
entryIds.push(localEntry.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
entryIds,
|
||||
thinkingLevel: piCtx.thinkingLevel,
|
||||
model: piCtx.model,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntryTimestamp(timestamp: string): number | undefined {
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function base64ImageInfo(block: unknown): { bytes: number; mime?: string } | null {
|
||||
if (!isRecord(block) || block.type !== "image") return null;
|
||||
|
||||
let data: string | undefined;
|
||||
let mime: string | undefined;
|
||||
if (typeof block.data === "string") {
|
||||
data = block.data;
|
||||
mime = typeof block.mimeType === "string" ? block.mimeType : undefined;
|
||||
} else if (isRecord(block.source) && block.source.type === "base64" && typeof block.source.data === "string") {
|
||||
data = block.source.data;
|
||||
mime = typeof block.source.media_type === "string" ? block.source.media_type : undefined;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
||||
return { bytes: Math.max(0, Math.floor(data.length * 3 / 4) - padding), mime };
|
||||
}
|
||||
|
||||
function omitToolResultBase64Images(message: AgentMessage): AgentMessage {
|
||||
if (message.role !== "toolResult") return message;
|
||||
|
||||
let omitted = 0;
|
||||
let bytes = 0;
|
||||
const mimes = new Set<string>();
|
||||
const content = message.content.filter((block) => {
|
||||
const image = base64ImageInfo(block);
|
||||
if (!image) return true;
|
||||
omitted += 1;
|
||||
bytes += image.bytes;
|
||||
if (image.mime) mimes.add(image.mime);
|
||||
return false;
|
||||
});
|
||||
if (omitted === 0) return message;
|
||||
|
||||
const mimeText = mimes.size > 0 ? `: ${[...mimes].join(", ")}` : "";
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `[${omitted} tool result image${omitted === 1 ? "" : "s"} omitted from initial history payload${mimeText}, ~${bytes} bytes]`,
|
||||
});
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
// Convert a session entry on the active branch into a UI message.
|
||||
// Returns null for entries that do not map to chat history (metadata, non-message types).
|
||||
function entryToUiMessage(
|
||||
entry: SessionEntry,
|
||||
options: { deferThinking?: boolean; deferToolResultImages?: boolean },
|
||||
): AgentMessage | null {
|
||||
// Supported message roles: user, assistant, toolResult, bashExecution.
|
||||
// bashExecution messages enter the case "message" branch (entry.type === "message").
|
||||
// The early return at line below ("!options.deferThinking || message.role !== "assistant"")
|
||||
// passes non-assistant messages — including bashExecution — through unchanged.
|
||||
// normalizeToolCalls is a secondary guard (returns non-assistant messages as-is).
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const message = options.deferToolResultImages
|
||||
? omitToolResultBase64Images(normalizeToolCalls(entry.message))
|
||||
: normalizeToolCalls(entry.message);
|
||||
if (!options.deferThinking || message.role !== "assistant") return message;
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((block) => (
|
||||
block.type === "thinking" && block.thinking.trim() !== ""
|
||||
? { ...block, thinking: "", deferred: true }
|
||||
: block
|
||||
)),
|
||||
};
|
||||
}
|
||||
case "compaction":
|
||||
return {
|
||||
role: "custom",
|
||||
customType: "compaction",
|
||||
content: entry.summary,
|
||||
display: true,
|
||||
details: {
|
||||
tokensBefore: entry.tokensBefore,
|
||||
firstKeptEntryId: entry.firstKeptEntryId,
|
||||
},
|
||||
timestamp: parseEntryTimestamp(entry.timestamp),
|
||||
};
|
||||
case "branch_summary":
|
||||
if (!entry.summary) return null;
|
||||
return {
|
||||
role: "user",
|
||||
content: `*The conversation briefly explored another branch and returned with this summary:*\n\n${entry.summary}`,
|
||||
timestamp: parseEntryTimestamp(entry.timestamp),
|
||||
};
|
||||
case "custom_message":
|
||||
return {
|
||||
role: "custom",
|
||||
customType: entry.customType,
|
||||
content: entry.content,
|
||||
display: entry.display,
|
||||
details: entry.details,
|
||||
timestamp: parseEntryTimestamp(entry.timestamp),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const {
|
||||
appendTitleRequestToTrailingUser,
|
||||
buildSessionTitleAgentOptions,
|
||||
generateSessionTitle,
|
||||
parseGeneratedSessionTitle,
|
||||
sanitizeTitleMessages,
|
||||
} = await jiti.import("./session-title.ts");
|
||||
|
||||
function assistantMessage(text) {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "test",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
test("cleans common session title response wrappers", () => {
|
||||
assert.equal(parseGeneratedSessionTitle("标题:修复 SSE 重连。"), "修复 SSE 重连");
|
||||
assert.equal(parseGeneratedSessionTitle('```json\n{"title":"整理 Session 文件夹"}\n```'), "整理 Session 文件夹");
|
||||
assert.equal(parseGeneratedSessionTitle('"Improve worktree session grouping"'), "Improve worktree session grouping");
|
||||
});
|
||||
|
||||
test("rejects responses without a usable title", () => {
|
||||
assert.throws(() => parseGeneratedSessionTitle("```\n---\n```"), /usable session title/);
|
||||
});
|
||||
|
||||
test("folds the title request into a trailing user message without mutating the source", () => {
|
||||
const source = [
|
||||
{ role: "assistant", content: [], timestamp: 1 },
|
||||
{ role: "user", content: [{ type: "text", text: "Fix the running-session race" }], timestamp: 2 },
|
||||
];
|
||||
|
||||
const prepared = appendTitleRequestToTrailingUser(source);
|
||||
|
||||
assert.deepEqual(prepared.map((message) => message.role), ["assistant", "user"]);
|
||||
assert.match(prepared[1].content.at(-1).text, /Create a concise title/);
|
||||
assert.equal(source[1].content.length, 1);
|
||||
assert.notEqual(prepared[1], source[1]);
|
||||
});
|
||||
|
||||
test("leaves a completed conversation unchanged before adding the title turn", () => {
|
||||
const source = [
|
||||
{ role: "user", content: "Fix it", timestamp: 1 },
|
||||
{ role: "assistant", content: [], timestamp: 2 },
|
||||
];
|
||||
|
||||
assert.equal(appendTitleRequestToTrailingUser(source), source);
|
||||
});
|
||||
|
||||
test("waits for the source reply before sending the title prompt", async () => {
|
||||
let sourceReplyFinished = false;
|
||||
let providerRoles;
|
||||
const sourceAgent = {
|
||||
state: {
|
||||
systemPrompt: "system",
|
||||
model: { provider: "test", id: "test-model" },
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
messages: [{ role: "user", content: "Implement auto name", timestamp: 1 }],
|
||||
},
|
||||
waitForIdle: async () => {
|
||||
sourceAgent.state.messages.push(assistantMessage("The implementation is complete"));
|
||||
sourceReplyFinished = true;
|
||||
},
|
||||
convertToLlm: (messages) => messages,
|
||||
streamFunction: (_model, context) => {
|
||||
assert.equal(sourceReplyFinished, true);
|
||||
providerRoles = context.messages.map((message) => message.role);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: assistantMessage("Wait for Complete Agent Reply"),
|
||||
});
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
sessionId: "source-session-id",
|
||||
};
|
||||
|
||||
const result = await generateSessionTitle({ agent: sourceAgent });
|
||||
|
||||
assert.equal(result.title, "Wait for Complete Agent Reply");
|
||||
assert.deepEqual(providerRoles, ["user", "assistant", "user"]);
|
||||
});
|
||||
|
||||
test("temporary title agent preserves the provider-facing prefix", async () => {
|
||||
const model = { provider: "test", id: "cached-model" };
|
||||
const messages = [{ role: "user", content: [{ type: "text", text: "Fix it" }] }];
|
||||
const originalExecute = async () => ({ content: [], details: {} });
|
||||
const tools = [{
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: "Read a file",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: originalExecute,
|
||||
}];
|
||||
const convertToLlm = (value) => value;
|
||||
const transformContext = async (value) => value;
|
||||
const streamFunction = () => { throw new Error("not called"); };
|
||||
const source = {
|
||||
state: {
|
||||
systemPrompt: "cached system prompt",
|
||||
model,
|
||||
thinkingLevel: "high",
|
||||
tools,
|
||||
messages,
|
||||
},
|
||||
convertToLlm,
|
||||
transformContext,
|
||||
streamFunction,
|
||||
steeringMode: "one-at-a-time",
|
||||
followUpMode: "one-at-a-time",
|
||||
sessionId: "source-session-id",
|
||||
transport: "sse",
|
||||
toolExecution: "parallel",
|
||||
};
|
||||
|
||||
const options = buildSessionTitleAgentOptions(source);
|
||||
|
||||
assert.equal(options.initialState.systemPrompt, source.state.systemPrompt);
|
||||
assert.equal(options.initialState.model, model);
|
||||
assert.equal(options.initialState.thinkingLevel, "high");
|
||||
assert.equal(options.initialState.messages, messages);
|
||||
assert.equal(options.convertToLlm, convertToLlm);
|
||||
assert.equal(options.transformContext, transformContext);
|
||||
assert.equal(options.streamFn, streamFunction);
|
||||
assert.equal(options.sessionId, "source-session-id");
|
||||
const withoutExecute = (tool) => Object.fromEntries(
|
||||
Object.entries(tool).filter(([key]) => key !== "execute"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
options.initialState.tools.map(withoutExecute),
|
||||
tools.map(withoutExecute),
|
||||
);
|
||||
assert.notEqual(options.initialState.tools[0].execute, originalExecute);
|
||||
await assert.rejects(
|
||||
options.initialState.tools[0].execute("call", {}, undefined, undefined),
|
||||
/cannot be executed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps only tool calls with adjacent matching results", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "inspect both files", timestamp: 1 },
|
||||
{
|
||||
...assistantMessage("Inspecting files"),
|
||||
content: [
|
||||
{ type: "text", text: "Inspecting files" },
|
||||
{ type: "toolCall", id: "call-complete", name: "read", arguments: { path: "a.txt" } },
|
||||
{ type: "toolCall", id: "call-incomplete", name: "read", arguments: { path: "b.txt" } },
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-complete",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "file contents" }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const sanitized = sanitizeTitleMessages(messages);
|
||||
|
||||
assert.deepEqual(
|
||||
sanitized[1].content.filter((block) => block.type === "toolCall").map((block) => block.id),
|
||||
["call-complete"],
|
||||
);
|
||||
assert.equal(sanitized[2], messages[2]);
|
||||
assert.equal(messages[1].content.length, 3);
|
||||
});
|
||||
|
||||
test("removes incomplete tool calls before invoking the title provider", async () => {
|
||||
let providerMessages;
|
||||
const sourceAgent = {
|
||||
state: {
|
||||
systemPrompt: "system",
|
||||
model: { provider: "test", id: "test-model" },
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
messages: [
|
||||
{ role: "user", content: "run a command", timestamp: 1 },
|
||||
{
|
||||
...assistantMessage(""),
|
||||
content: [{
|
||||
type: "toolCall",
|
||||
id: "call-incomplete",
|
||||
name: "bash",
|
||||
arguments: { command: "sleep 10" },
|
||||
}],
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
],
|
||||
},
|
||||
waitForIdle: async () => {},
|
||||
convertToLlm: (messages) => messages,
|
||||
streamFunction: (_model, context) => {
|
||||
providerMessages = context.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: assistantMessage("Sanitized Tool Call History"),
|
||||
});
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
sessionId: "source-session-id",
|
||||
};
|
||||
|
||||
const result = await generateSessionTitle({ agent: sourceAgent });
|
||||
|
||||
assert.equal(result.title, "Sanitized Tool Call History");
|
||||
assert.deepEqual(providerMessages.map((message) => message.role), ["user"]);
|
||||
assert.match(providerMessages[0].content, /Create a concise title/);
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
import {
|
||||
Agent,
|
||||
type AgentMessage,
|
||||
type AgentOptions,
|
||||
type AgentTool,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const TITLE_TIMEOUT_MS = 90_000;
|
||||
const MAX_TITLE_LENGTH = 80;
|
||||
|
||||
const TITLE_PROMPT = `Create a concise title for this session based on the conversation above.
|
||||
|
||||
Requirements:
|
||||
- Match the primary language used by the user.
|
||||
- Describe the user's concrete goal or the outcome, not the act of chatting.
|
||||
- Use 4-12 words for space-separated languages, or 8-24 characters for CJK text when practical.
|
||||
- Do not call any tools.
|
||||
- Return only the title as plain text, with no quotes, label, markdown, or explanation.`;
|
||||
|
||||
export interface GeneratedSessionTitle {
|
||||
title: string;
|
||||
usage?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
function createShadowTools(tools: AgentTool[]): AgentTool[] {
|
||||
return tools.map((tool) => ({
|
||||
...tool,
|
||||
execute: async () => {
|
||||
throw new Error("Tools cannot be executed while generating a session title");
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temporary Agent configuration whose provider-facing prefix matches
|
||||
* the source Agent. Tool implementations are replaced without changing their
|
||||
* names, descriptions, or schemas, so a naming run cannot mutate the project.
|
||||
*/
|
||||
export function buildSessionTitleAgentOptions(source: Agent): AgentOptions {
|
||||
const state = source.state;
|
||||
return {
|
||||
initialState: {
|
||||
systemPrompt: state.systemPrompt,
|
||||
model: state.model,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
tools: createShadowTools(state.tools),
|
||||
messages: state.messages,
|
||||
},
|
||||
convertToLlm: source.convertToLlm,
|
||||
transformContext: source.transformContext,
|
||||
streamFn: source.streamFunction,
|
||||
getApiKey: source.getApiKey,
|
||||
onPayload: source.onPayload,
|
||||
onResponse: source.onResponse,
|
||||
steeringMode: source.steeringMode,
|
||||
followUpMode: source.followUpMode,
|
||||
sessionId: source.sessionId,
|
||||
thinkingBudgets: source.thinkingBudgets,
|
||||
transport: source.transport,
|
||||
maxRetryDelayMs: source.maxRetryDelayMs,
|
||||
toolExecution: source.toolExecution,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A running source session usually ends in the user message currently being
|
||||
* answered. Fold the title request into a copy of that message so the title
|
||||
* request does not send two consecutive user messages to the provider.
|
||||
*/
|
||||
export function appendTitleRequestToTrailingUser(messages: AgentMessage[]): AgentMessage[] {
|
||||
const lastMessage = messages.at(-1);
|
||||
if (!lastMessage || lastMessage.role !== "user") return messages;
|
||||
|
||||
const content = typeof lastMessage.content === "string"
|
||||
? `${lastMessage.content}\n\n${TITLE_PROMPT}`
|
||||
: [...lastMessage.content, { type: "text" as const, text: TITLE_PROMPT }];
|
||||
|
||||
return [
|
||||
...messages.slice(0, -1),
|
||||
{ ...lastMessage, content },
|
||||
];
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
const pairs: Array<[string, string]> = [
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["`", "`"],
|
||||
["\u201c", "\u201d"],
|
||||
["\u300c", "\u300d"],
|
||||
["\u300e", "\u300f"],
|
||||
];
|
||||
for (const [start, end] of pairs) {
|
||||
if (value.startsWith(start) && value.endsWith(end) && value.length > start.length + end.length) {
|
||||
return value.slice(start.length, -end.length).trim();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseGeneratedSessionTitle(raw: string): string {
|
||||
let value = raw.trim();
|
||||
const fenced = value.match(/^```(?:json|text)?\s*([\s\S]*?)\s*```$/i);
|
||||
if (fenced) value = fenced[1].trim();
|
||||
|
||||
if (value.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as { title?: unknown };
|
||||
if (typeof parsed.title === "string") value = parsed.title.trim();
|
||||
} catch {
|
||||
// Fall back to plain-text cleanup below.
|
||||
}
|
||||
}
|
||||
|
||||
value = value.split(/\r?\n/, 1)[0] ?? "";
|
||||
value = value.replace(/^(?:session\s+title|title|标题)\s*[::-]\s*/i, "");
|
||||
value = stripWrappingQuotes(value).replace(/\s+/g, " ").trim();
|
||||
value = value.replace(/[。.!]+$/u, "").trim();
|
||||
|
||||
if (!/[\p{L}\p{N}]/u.test(value)) {
|
||||
throw new Error("The model did not return a usable session title");
|
||||
}
|
||||
|
||||
const characters = Array.from(value);
|
||||
if (characters.length > MAX_TITLE_LENGTH) {
|
||||
value = characters.slice(0, MAX_TITLE_LENGTH).join("").trim();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getAssistantResult(agent: Agent, historyLength: number): GeneratedSessionTitle {
|
||||
const generatedMessages = agent.state.messages.slice(historyLength);
|
||||
for (let i = generatedMessages.length - 1; i >= 0; i--) {
|
||||
const message = generatedMessages[i];
|
||||
if (message.role !== "assistant") continue;
|
||||
if (message.stopReason === "error") {
|
||||
throw new Error(message.errorMessage || "The title model request failed");
|
||||
}
|
||||
const text = message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!text) continue;
|
||||
return {
|
||||
title: parseGeneratedSessionTitle(text),
|
||||
...(message.usage ? {
|
||||
usage: {
|
||||
input: message.usage.input,
|
||||
output: message.usage.output,
|
||||
cacheRead: message.usage.cacheRead,
|
||||
cacheWrite: message.usage.cacheWrite,
|
||||
total: message.usage.totalTokens,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
throw new Error("The model did not return a session title");
|
||||
}
|
||||
|
||||
export function sanitizeTitleMessages(messages: AgentMessage[]): AgentMessage[] {
|
||||
const sanitized: AgentMessage[] = [];
|
||||
let expectedToolResultIds: Set<string> | undefined;
|
||||
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const message = messages[index];
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const followingToolResultIds = new Set<string>();
|
||||
for (let resultIndex = index + 1; resultIndex < messages.length; resultIndex++) {
|
||||
const resultMessage = messages[resultIndex];
|
||||
if (resultMessage.role !== "toolResult") break;
|
||||
followingToolResultIds.add(resultMessage.toolCallId);
|
||||
}
|
||||
|
||||
expectedToolResultIds = new Set<string>();
|
||||
const content = message.content.filter((block) => {
|
||||
if (block.type !== "toolCall") return true;
|
||||
if (!followingToolResultIds.has(block.id)) return false;
|
||||
expectedToolResultIds!.add(block.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (content.length > 0) {
|
||||
sanitized.push({ ...message, content });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "toolResult") {
|
||||
if (expectedToolResultIds?.delete(message.toolCallId)) {
|
||||
sanitized.push(message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
expectedToolResultIds = undefined;
|
||||
sanitized.push(message);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export async function generateSessionTitle(source: AgentSession): Promise<GeneratedSessionTitle> {
|
||||
const sourceAgent = source.agent;
|
||||
await sourceAgent.waitForIdle();
|
||||
|
||||
const sanitizedMessages = sanitizeTitleMessages(sourceAgent.state.messages);
|
||||
const historyLength = sanitizedMessages.length;
|
||||
if (!sanitizedMessages.some((message) => message.role === "user")) {
|
||||
throw new Error("The session has no user messages to name");
|
||||
}
|
||||
|
||||
const options = buildSessionTitleAgentOptions(sourceAgent);
|
||||
options.initialState!.messages = sanitizedMessages;
|
||||
const continuesFromTrailingUser = sanitizedMessages.at(-1)?.role === "user";
|
||||
if (continuesFromTrailingUser) {
|
||||
options.initialState!.messages = appendTitleRequestToTrailingUser(sanitizedMessages);
|
||||
}
|
||||
|
||||
const temporaryAgent = new Agent(options);
|
||||
const runPromise = continuesFromTrailingUser
|
||||
? temporaryAgent.continue()
|
||||
: temporaryAgent.prompt(TITLE_PROMPT);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
runPromise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
temporaryAgent.abort();
|
||||
reject(new Error("Session title generation timed out"));
|
||||
}, TITLE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
temporaryAgent.abort();
|
||||
await runPromise.catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return getAssistantResult(temporaryAgent, historyLength);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const { annotateSkillsWithInstallInfo, getGlobalSkillsLockPath } =
|
||||
await jiti.import("./skill-lock.ts");
|
||||
|
||||
function writeJson(path, value) {
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function makeSkill(name, filePath, scope) {
|
||||
return {
|
||||
name,
|
||||
description: `${name} description`,
|
||||
filePath,
|
||||
baseDir: filePath.slice(0, -"/SKILL.md".length),
|
||||
disableModelInvocation: false,
|
||||
sourceInfo: { scope },
|
||||
};
|
||||
}
|
||||
|
||||
test("uses the CLI global lock location", () => {
|
||||
assert.equal(
|
||||
getGlobalSkillsLockPath({ homeDir: "/home/test", xdgStateHome: undefined }),
|
||||
join("/home/test", ".agents", ".skill-lock.json"),
|
||||
);
|
||||
assert.equal(
|
||||
getGlobalSkillsLockPath({ homeDir: "/home/test", xdgStateHome: "/state" }),
|
||||
join("/state", "skills", ".skill-lock.json"),
|
||||
);
|
||||
});
|
||||
|
||||
test("annotates only lock entries that exist in the matching Pi scope", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-web-skill-lock-"));
|
||||
try {
|
||||
const cwd = join(root, "project");
|
||||
const agentDir = join(root, "home", ".pi", "agent");
|
||||
const globalLockPath = join(root, "global-lock.json");
|
||||
const projectLockPath = join(cwd, "skills-lock.json");
|
||||
const globalSkillPath = join(agentDir, "skills", "edge-tts", "SKILL.md");
|
||||
const projectSkillPath = join(cwd, ".pi", "skills", "find-skills", "SKILL.md");
|
||||
const manualSkillPath = join(agentDir, "skills", "manual", "SKILL.md");
|
||||
const otherAgentSkillPath = join(root, "other-agent", "tts", "SKILL.md");
|
||||
|
||||
for (const path of [globalSkillPath, projectSkillPath, manualSkillPath, otherAgentSkillPath]) {
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, "---\nname: test\n---\n", "utf8");
|
||||
}
|
||||
|
||||
writeJson(globalLockPath, {
|
||||
version: 3,
|
||||
skills: {
|
||||
"edge-tts": {
|
||||
source: "https://github.com/aahl/skills.git",
|
||||
sourceType: "github",
|
||||
skillPath: "skills/edge-tts/SKILL.md",
|
||||
skillFolderHash: "global-version",
|
||||
},
|
||||
tts: { source: "noizai/skills", sourceType: "github" },
|
||||
},
|
||||
});
|
||||
writeJson(projectLockPath, {
|
||||
version: 1,
|
||||
skills: {
|
||||
"find-skills": {
|
||||
source: "vercel-labs/skills",
|
||||
sourceType: "github",
|
||||
skillPath: "skills/find-skills/SKILL.md",
|
||||
computedHash: "project-version",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const annotated = annotateSkillsWithInstallInfo(
|
||||
[
|
||||
makeSkill("edge-tts", globalSkillPath, "user"),
|
||||
makeSkill("find-skills", projectSkillPath, "project"),
|
||||
makeSkill("manual", manualSkillPath, "user"),
|
||||
makeSkill("tts", otherAgentSkillPath, "user"),
|
||||
],
|
||||
{ cwd, agentDir, globalLockPath, projectLockPath },
|
||||
);
|
||||
|
||||
assert.deepEqual(annotated[0].install, {
|
||||
package: "aahl/skills@edge-tts",
|
||||
scope: "global",
|
||||
source: "aahl/skills",
|
||||
sourceType: "github",
|
||||
skillsShUrl: "https://skills.sh/aahl/skills/edge-tts",
|
||||
skillPath: "skills/edge-tts/SKILL.md",
|
||||
versionHash: "global-version",
|
||||
canCheckForUpdates: true,
|
||||
});
|
||||
assert.deepEqual(annotated[1].install, {
|
||||
package: "vercel-labs/skills@find-skills",
|
||||
scope: "project",
|
||||
source: "vercel-labs/skills",
|
||||
sourceType: "github",
|
||||
skillsShUrl: "https://skills.sh/vercel-labs/skills/find-skills",
|
||||
skillPath: "skills/find-skills/SKILL.md",
|
||||
versionHash: "project-version",
|
||||
canCheckForUpdates: true,
|
||||
});
|
||||
assert.equal(annotated[2].install, undefined);
|
||||
assert.equal(annotated[3].install, undefined);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ignores stale lock entries and malformed lock files", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-web-skill-lock-"));
|
||||
try {
|
||||
const cwd = join(root, "project");
|
||||
const agentDir = join(root, "agent");
|
||||
const missingPath = join(agentDir, "skills", "missing", "SKILL.md");
|
||||
const projectSkillPath = join(cwd, ".pi", "skills", "broken", "SKILL.md");
|
||||
const globalLockPath = join(root, "global-lock.json");
|
||||
const projectLockPath = join(root, "project-lock.json");
|
||||
mkdirSync(join(projectSkillPath, ".."), { recursive: true });
|
||||
writeFileSync(projectSkillPath, "---\nname: broken\n---\n", "utf8");
|
||||
writeJson(globalLockPath, {
|
||||
version: 3,
|
||||
skills: {
|
||||
missing: { source: "owner/repo", sourceType: "github" },
|
||||
},
|
||||
});
|
||||
writeFileSync(projectLockPath, "not json", "utf8");
|
||||
|
||||
const skills = annotateSkillsWithInstallInfo(
|
||||
[
|
||||
makeSkill("missing", missingPath, "user"),
|
||||
makeSkill("broken", projectSkillPath, "project"),
|
||||
],
|
||||
{ cwd, agentDir, globalLockPath, projectLockPath },
|
||||
);
|
||||
|
||||
assert.equal(skills[0].install, undefined);
|
||||
assert.equal(skills[1].install, undefined);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("does not compare a project ref with the default skills.sh snapshot", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-web-skill-lock-"));
|
||||
try {
|
||||
const cwd = join(root, "project");
|
||||
const agentDir = join(root, "agent");
|
||||
const projectLockPath = join(cwd, "skills-lock.json");
|
||||
const projectSkillPath = join(cwd, ".pi", "skills", "preview", "SKILL.md");
|
||||
mkdirSync(join(projectSkillPath, ".."), { recursive: true });
|
||||
writeFileSync(projectSkillPath, "---\nname: preview\n---\n", "utf8");
|
||||
writeJson(projectLockPath, {
|
||||
version: 1,
|
||||
skills: {
|
||||
preview: {
|
||||
source: "owner/repo",
|
||||
sourceType: "github",
|
||||
skillPath: "skills/preview/SKILL.md",
|
||||
ref: "preview",
|
||||
computedHash: "project-version",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [skill] = annotateSkillsWithInstallInfo(
|
||||
[makeSkill("preview", projectSkillPath, "project")],
|
||||
{ cwd, agentDir, projectLockPath, globalLockPath: join(root, "missing.json") },
|
||||
);
|
||||
|
||||
assert.equal(skill.install.ref, "preview");
|
||||
assert.equal(skill.install.canCheckForUpdates, false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "path";
|
||||
import type { SkillInfo, SkillInstallInfo, SkillInstallScope } from "@/lib/api-types";
|
||||
|
||||
interface SkillLockEntry {
|
||||
source?: unknown;
|
||||
sourceType?: unknown;
|
||||
skillPath?: unknown;
|
||||
ref?: unknown;
|
||||
skillFolderHash?: unknown;
|
||||
computedHash?: unknown;
|
||||
}
|
||||
|
||||
interface SkillLockFile {
|
||||
skills?: Record<string, SkillLockEntry>;
|
||||
}
|
||||
|
||||
interface GlobalLockPathOptions {
|
||||
homeDir?: string;
|
||||
xdgStateHome?: string;
|
||||
}
|
||||
|
||||
interface AnnotateSkillOptions {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
globalSkillRoots?: string[];
|
||||
globalLockPath?: string;
|
||||
projectLockPath?: string;
|
||||
}
|
||||
|
||||
export function getGlobalSkillsLockPath({
|
||||
homeDir = homedir(),
|
||||
xdgStateHome = process.env.XDG_STATE_HOME,
|
||||
}: GlobalLockPathOptions = {}): string {
|
||||
return xdgStateHome
|
||||
? join(xdgStateHome, "skills", ".skill-lock.json")
|
||||
: join(homeDir, ".agents", ".skill-lock.json");
|
||||
}
|
||||
|
||||
function readSkillLock(path: string): Record<string, SkillLockEntry> {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as SkillLockFile;
|
||||
return parsed.skills && typeof parsed.skills === "object" ? parsed.skills : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isWithin(path: string, root: string): boolean {
|
||||
const rel = relative(resolve(root), resolve(path));
|
||||
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
function findLockEntry(
|
||||
entries: Record<string, SkillLockEntry>,
|
||||
skillName: string,
|
||||
): SkillLockEntry | undefined {
|
||||
if (entries[skillName]) return entries[skillName];
|
||||
const normalizedName = skillName.toLowerCase();
|
||||
const key = Object.keys(entries).find((name) => name.toLowerCase() === normalizedName);
|
||||
return key ? entries[key] : undefined;
|
||||
}
|
||||
|
||||
function normalizeSource(source: string, sourceType?: string): string {
|
||||
if (sourceType !== "github") return source.replace(/\/$/, "");
|
||||
return source
|
||||
.replace(/^git\+/, "")
|
||||
.replace(/^https?:\/\/github\.com\//, "")
|
||||
.replace(/^git@github\.com:/, "")
|
||||
.replace(/\.git$/, "")
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function buildSkillsShUrl(source: string, skillName: string): string | undefined {
|
||||
if (!source || source.includes("://") || source.startsWith("git@")) return undefined;
|
||||
const sourcePath = source
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
if (!sourcePath) return undefined;
|
||||
return `https://skills.sh/${sourcePath}/${encodeURIComponent(skillName)}`;
|
||||
}
|
||||
|
||||
function getInstallInfo(
|
||||
entries: Record<string, SkillLockEntry>,
|
||||
skillName: string,
|
||||
scope: SkillInstallScope,
|
||||
): SkillInstallInfo | undefined {
|
||||
const entry = findLockEntry(entries, skillName);
|
||||
if (!entry || typeof entry.source !== "string" || !entry.source.trim()) return undefined;
|
||||
|
||||
const sourceType = typeof entry.sourceType === "string" ? entry.sourceType : undefined;
|
||||
const source = normalizeSource(entry.source.trim(), sourceType);
|
||||
if (!source) return undefined;
|
||||
const skillPath = typeof entry.skillPath === "string" ? entry.skillPath : undefined;
|
||||
const ref = typeof entry.ref === "string" ? entry.ref : undefined;
|
||||
const rawVersionHash = scope === "global" ? entry.skillFolderHash : entry.computedHash;
|
||||
const versionHash = typeof rawVersionHash === "string" && rawVersionHash
|
||||
? rawVersionHash
|
||||
: undefined;
|
||||
const isGitHubSource =
|
||||
sourceType === "github" && /^[\w.-]+\/[\w.-]+$/.test(source);
|
||||
const hasComparableVersion = scope === "global" || !ref;
|
||||
|
||||
return {
|
||||
package: `${source}@${skillName}`,
|
||||
scope,
|
||||
source,
|
||||
sourceType,
|
||||
skillsShUrl: sourceType === "local" ? undefined : buildSkillsShUrl(source, skillName),
|
||||
...(skillPath && { skillPath }),
|
||||
...(ref && { ref }),
|
||||
...(versionHash && { versionHash }),
|
||||
canCheckForUpdates: Boolean(
|
||||
isGitHubSource && skillPath && versionHash && hasComparableVersion,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function annotateSkillsWithInstallInfo(
|
||||
skills: SkillInfo[],
|
||||
{
|
||||
cwd,
|
||||
agentDir,
|
||||
globalSkillRoots = [join(agentDir, "skills")],
|
||||
globalLockPath = getGlobalSkillsLockPath(),
|
||||
projectLockPath = join(cwd, "skills-lock.json"),
|
||||
}: AnnotateSkillOptions,
|
||||
): SkillInfo[] {
|
||||
const globalEntries = readSkillLock(globalLockPath);
|
||||
const projectEntries = readSkillLock(projectLockPath);
|
||||
const projectSkillsRoot = join(cwd, ".pi", "skills");
|
||||
|
||||
return skills.map((skill) => {
|
||||
if (!existsSync(skill.filePath)) return skill;
|
||||
|
||||
const install = globalSkillRoots.some((root) => isWithin(skill.filePath, root))
|
||||
? getInstallInfo(globalEntries, skill.name, "global")
|
||||
: isWithin(skill.filePath, projectSkillsRoot)
|
||||
? getInstallInfo(projectEntries, skill.name, "project")
|
||||
: undefined;
|
||||
|
||||
return install ? { ...skill, install } : skill;
|
||||
});
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const {
|
||||
buildSkillUpdateArgs,
|
||||
checkSkillUpdate,
|
||||
checkSkillUpdates,
|
||||
skillUpdateKey,
|
||||
} = await jiti.import("./skill-updates.ts");
|
||||
|
||||
function install(overrides = {}) {
|
||||
return {
|
||||
package: "owner/repo@example-skill",
|
||||
scope: "global",
|
||||
source: "owner/repo",
|
||||
sourceType: "github",
|
||||
skillsShUrl: "https://skills.sh/owner/repo/example-skill",
|
||||
skillPath: "skills/example-skill/SKILL.md",
|
||||
versionHash: "current-hash",
|
||||
canCheckForUpdates: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value, status = 200) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
test("compares a global lock version with the remote Git tree", async () => {
|
||||
const seen = [];
|
||||
const upToDate = await checkSkillUpdate(install(), {
|
||||
fetcher: async (url) => {
|
||||
seen.push(url);
|
||||
return jsonResponse({
|
||||
sha: "root-hash",
|
||||
tree: [{ type: "tree", path: "skills/example-skill", sha: "current-hash" }],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(upToDate.state, "up-to-date");
|
||||
assert.equal(upToDate.latestVersion, "current-hash");
|
||||
assert.match(seen[0], /repos\/owner\/repo\/git\/trees\/HEAD/);
|
||||
|
||||
const available = await checkSkillUpdate(install(), {
|
||||
fetcher: async () => jsonResponse({
|
||||
sha: "root-hash",
|
||||
tree: [{ type: "tree", path: "skills/example-skill", sha: "next-hash" }],
|
||||
}),
|
||||
});
|
||||
assert.equal(available.state, "update-available");
|
||||
assert.equal(available.currentVersion, "current-hash");
|
||||
assert.equal(available.latestVersion, "next-hash");
|
||||
});
|
||||
|
||||
test("uses the repository hash for a root global skill", async () => {
|
||||
const result = await checkSkillUpdate(install({ skillPath: "SKILL.md" }), {
|
||||
fetcher: async () => jsonResponse({ sha: "next-root", tree: [] }),
|
||||
});
|
||||
|
||||
assert.equal(result.state, "update-available");
|
||||
assert.equal(result.latestVersion, "next-root");
|
||||
});
|
||||
|
||||
test("compares a project lock version with the skills.sh snapshot", async () => {
|
||||
let requestedUrl = "";
|
||||
const result = await checkSkillUpdate(install({ scope: "project" }), {
|
||||
skillsApiBase: "https://skills.test",
|
||||
fetcher: async (url) => {
|
||||
requestedUrl = url;
|
||||
return jsonResponse({ hash: "current-hash" });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.state, "up-to-date");
|
||||
assert.equal(
|
||||
requestedUrl,
|
||||
"https://skills.test/api/download/owner/repo/example-skill",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns unsupported without making a remote request", async () => {
|
||||
let called = false;
|
||||
const result = await checkSkillUpdate(
|
||||
install({ canCheckForUpdates: false, versionHash: undefined }),
|
||||
{ fetcher: async () => { called = true; return jsonResponse({}); } },
|
||||
);
|
||||
|
||||
assert.equal(result.state, "unsupported");
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("returns a scoped error when the remote check fails", async () => {
|
||||
const result = await checkSkillUpdate(install(), {
|
||||
fetcher: async () => jsonResponse({}, 503),
|
||||
});
|
||||
|
||||
assert.equal(result.state, "error");
|
||||
assert.equal(result.message, "HTTP 503");
|
||||
assert.equal(skillUpdateKey(install()), "global\0owner/repo@example-skill");
|
||||
});
|
||||
|
||||
test("falls back to Git when the GitHub API is rate limited", async () => {
|
||||
let resolved = false;
|
||||
const result = await checkSkillUpdate(install(), {
|
||||
fetcher: async () => jsonResponse({}, 403),
|
||||
resolveGitTreeHash: async () => {
|
||||
resolved = true;
|
||||
return "next-hash";
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved, true);
|
||||
assert.equal(result.state, "update-available");
|
||||
assert.equal(result.latestVersion, "next-hash");
|
||||
});
|
||||
|
||||
test("builds Pi-only update commands for each scope", () => {
|
||||
assert.deepEqual(buildSkillUpdateArgs(install()), [
|
||||
"skills",
|
||||
"add",
|
||||
"owner/repo/skills/example-skill",
|
||||
"--skill",
|
||||
"example-skill",
|
||||
"-y",
|
||||
"--agent",
|
||||
"pi",
|
||||
"-g",
|
||||
]);
|
||||
assert.deepEqual(buildSkillUpdateArgs(install({ scope: "project" })), [
|
||||
"skills",
|
||||
"add",
|
||||
"owner/repo/skills/example-skill",
|
||||
"--skill",
|
||||
"example-skill",
|
||||
"-y",
|
||||
"--agent",
|
||||
"pi",
|
||||
]);
|
||||
assert.deepEqual(buildSkillUpdateArgs(install({ ref: "release/v2" })), [
|
||||
"skills",
|
||||
"add",
|
||||
"owner/repo/skills/example-skill#release%2Fv2",
|
||||
"--skill",
|
||||
"example-skill",
|
||||
"-y",
|
||||
"--agent",
|
||||
"pi",
|
||||
"-g",
|
||||
]);
|
||||
});
|
||||
|
||||
test("reuses one remote request for skills from the same GitHub source", async () => {
|
||||
let requests = 0;
|
||||
const results = await checkSkillUpdates([
|
||||
install(),
|
||||
install({
|
||||
package: "owner/repo@another-skill",
|
||||
skillPath: "skills/another-skill/SKILL.md",
|
||||
versionHash: "another-hash",
|
||||
}),
|
||||
], {
|
||||
fetcher: async () => {
|
||||
requests++;
|
||||
return jsonResponse({
|
||||
sha: "root-hash",
|
||||
tree: [
|
||||
{ type: "tree", path: "skills/example-skill", sha: "current-hash" },
|
||||
{ type: "tree", path: "skills/another-skill", sha: "another-hash" },
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(requests, 1);
|
||||
assert.deepEqual(results.map((item) => item.state), ["up-to-date", "up-to-date"]);
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import { execFile } from "child_process";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { promisify } from "util";
|
||||
import type {
|
||||
SkillInstallInfo,
|
||||
SkillUpdateResult,
|
||||
} from "@/lib/api-types";
|
||||
|
||||
const CHECK_TIMEOUT_MS = 15_000;
|
||||
const GIT_CHECK_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_SKILLS_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh";
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type Fetcher = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
type GitTreeResolver = (install: SkillInstallInfo) => Promise<string>;
|
||||
|
||||
interface CheckOptions {
|
||||
fetcher?: Fetcher;
|
||||
skillsApiBase?: string;
|
||||
githubToken?: string;
|
||||
resolveGitTreeHash?: GitTreeResolver;
|
||||
}
|
||||
|
||||
interface GitHubTreeEntry {
|
||||
path?: unknown;
|
||||
type?: unknown;
|
||||
sha?: unknown;
|
||||
}
|
||||
|
||||
interface GitHubTreeResponse {
|
||||
sha?: unknown;
|
||||
tree?: unknown;
|
||||
}
|
||||
|
||||
interface SnapshotResponse {
|
||||
hash?: unknown;
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`HTTP ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function skillUpdateKey(install: Pick<SkillInstallInfo, "scope" | "package">): string {
|
||||
return `${install.scope}\0${install.package}`;
|
||||
}
|
||||
|
||||
export function buildSkillUpdateArgs(install: SkillInstallInfo): string[] {
|
||||
const folder = skillFolder(install.skillPath ?? "");
|
||||
const source = folder ? `${install.source}/${folder}` : install.source;
|
||||
const ref = install.ref ? `#${encodeURIComponent(install.ref)}` : "";
|
||||
const args = [
|
||||
"skills",
|
||||
"add",
|
||||
`${source}${ref}`,
|
||||
"--skill",
|
||||
skillNameFromPackage(install.package),
|
||||
"-y",
|
||||
"--agent",
|
||||
"pi",
|
||||
];
|
||||
if (install.scope === "global") args.push("-g");
|
||||
return args;
|
||||
}
|
||||
|
||||
function skillSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function skillNameFromPackage(pkg: string): string {
|
||||
const at = pkg.lastIndexOf("@");
|
||||
return at >= 0 ? pkg.slice(at + 1) : pkg;
|
||||
}
|
||||
|
||||
function skillFolder(skillPath: string): string {
|
||||
let folder = skillPath.replace(/\\/g, "/");
|
||||
if (folder.toLowerCase().endsWith("/skill.md")) folder = folder.slice(0, -9);
|
||||
else if (folder.toLowerCase().endsWith("skill.md")) folder = folder.slice(0, -8);
|
||||
return folder.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function result(
|
||||
install: SkillInstallInfo,
|
||||
state: SkillUpdateResult["state"],
|
||||
latestVersion?: string,
|
||||
message?: string,
|
||||
): SkillUpdateResult {
|
||||
return {
|
||||
package: install.package,
|
||||
scope: install.scope,
|
||||
state,
|
||||
currentVersion: install.versionHash,
|
||||
latestVersion,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
url: string,
|
||||
fetcher: Fetcher,
|
||||
headers?: HeadersInit,
|
||||
): Promise<unknown> {
|
||||
const response = await fetcher(url, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new HttpError(response.status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function resolveGitTreeHash(install: SkillInstallInfo): Promise<string> {
|
||||
const repository = `https://github.com/${install.source}.git`;
|
||||
const ref = install.ref || "HEAD";
|
||||
const folder = skillFolder(install.skillPath!);
|
||||
const gitDir = await mkdtemp(join(tmpdir(), "pi-web-skill-check-"));
|
||||
|
||||
try {
|
||||
await execFileAsync("git", ["init", "--bare", gitDir], {
|
||||
timeout: GIT_CHECK_TIMEOUT_MS,
|
||||
});
|
||||
await execFileAsync("git", [
|
||||
`--git-dir=${gitDir}`,
|
||||
"fetch",
|
||||
"--depth=1",
|
||||
"--filter=blob:none",
|
||||
"--no-tags",
|
||||
repository,
|
||||
ref,
|
||||
], { timeout: GIT_CHECK_TIMEOUT_MS });
|
||||
const revision = folder ? `FETCH_HEAD:${folder}` : "FETCH_HEAD^{tree}";
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
[`--git-dir=${gitDir}`, "rev-parse", revision],
|
||||
{ timeout: GIT_CHECK_TIMEOUT_MS },
|
||||
);
|
||||
const hash = stdout.trim();
|
||||
if (!/^[0-9a-f]{40}$/i.test(hash)) throw new Error("Invalid Git tree hash");
|
||||
return hash;
|
||||
} finally {
|
||||
await rm(gitDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGlobalSkill(
|
||||
install: SkillInstallInfo,
|
||||
options: Required<Pick<CheckOptions, "fetcher" | "resolveGitTreeHash">> & CheckOptions,
|
||||
): Promise<SkillUpdateResult> {
|
||||
const ref = install.ref || "HEAD";
|
||||
const url = `https://api.github.com/repos/${install.source}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "pi-web",
|
||||
};
|
||||
if (options.githubToken) headers.Authorization = `Bearer ${options.githubToken}`;
|
||||
const folder = skillFolder(install.skillPath!);
|
||||
let latestVersion: string | undefined;
|
||||
|
||||
try {
|
||||
const raw = (await fetchJson(url, options.fetcher, headers)) as GitHubTreeResponse;
|
||||
latestVersion = typeof raw.sha === "string" && !folder ? raw.sha : undefined;
|
||||
|
||||
if (folder && Array.isArray(raw.tree)) {
|
||||
const entry = (raw.tree as GitHubTreeEntry[]).find(
|
||||
(item) => item.type === "tree" && item.path === folder,
|
||||
);
|
||||
if (entry && typeof entry.sha === "string") latestVersion = entry.sha;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || ![401, 403, 429].includes(error.status)) {
|
||||
throw error;
|
||||
}
|
||||
latestVersion = await options.resolveGitTreeHash(install);
|
||||
}
|
||||
|
||||
if (!latestVersion) {
|
||||
return result(install, "error", undefined, "Remote skill path was not found.");
|
||||
}
|
||||
return result(
|
||||
install,
|
||||
latestVersion === install.versionHash ? "up-to-date" : "update-available",
|
||||
latestVersion,
|
||||
);
|
||||
}
|
||||
|
||||
async function checkProjectSkill(
|
||||
install: SkillInstallInfo,
|
||||
options: Required<Pick<CheckOptions, "fetcher" | "skillsApiBase">> & CheckOptions,
|
||||
): Promise<SkillUpdateResult> {
|
||||
const [owner, repo] = install.source.split("/");
|
||||
const name = skillSlug(skillNameFromPackage(install.package));
|
||||
const url = `${options.skillsApiBase}/api/download/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(name)}`;
|
||||
const raw = (await fetchJson(url, options.fetcher)) as SnapshotResponse;
|
||||
const latestVersion = typeof raw.hash === "string" ? raw.hash : undefined;
|
||||
if (!latestVersion) {
|
||||
return result(install, "error", undefined, "skills.sh did not return a version hash.");
|
||||
}
|
||||
return result(
|
||||
install,
|
||||
latestVersion === install.versionHash ? "up-to-date" : "update-available",
|
||||
latestVersion,
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkSkillUpdate(
|
||||
install: SkillInstallInfo,
|
||||
options: CheckOptions = {},
|
||||
): Promise<SkillUpdateResult> {
|
||||
if (!install.canCheckForUpdates || !install.versionHash || !install.skillPath) {
|
||||
return result(install, "unsupported", undefined, "This lock entry cannot be checked automatically.");
|
||||
}
|
||||
|
||||
const resolvedOptions = {
|
||||
...options,
|
||||
fetcher: options.fetcher ?? fetch,
|
||||
skillsApiBase: options.skillsApiBase ?? DEFAULT_SKILLS_API_BASE,
|
||||
resolveGitTreeHash: options.resolveGitTreeHash ?? resolveGitTreeHash,
|
||||
};
|
||||
|
||||
try {
|
||||
return install.scope === "global"
|
||||
? await checkGlobalSkill(install, resolvedOptions)
|
||||
: await checkProjectSkill(install, resolvedOptions);
|
||||
} catch (error) {
|
||||
return result(
|
||||
install,
|
||||
"error",
|
||||
undefined,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSkillUpdates(
|
||||
installs: SkillInstallInfo[],
|
||||
options: CheckOptions = {},
|
||||
): Promise<SkillUpdateResult[]> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const requests = new Map<string, Promise<Response>>();
|
||||
const cachedFetcher: Fetcher = async (input, init) => {
|
||||
let request = requests.get(input);
|
||||
if (!request) {
|
||||
request = fetcher(input, init);
|
||||
requests.set(input, request);
|
||||
}
|
||||
return (await request).clone();
|
||||
};
|
||||
|
||||
return Promise.all(
|
||||
installs.map((install) => checkSkillUpdate(install, {
|
||||
...options,
|
||||
fetcher: cachedFetcher,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import type { SkillInfo, SkillsResponse } from "@/lib/api-types";
|
||||
import { annotateSkillsWithInstallInfo } from "@/lib/skill-lock";
|
||||
import { getProjectTrustStatus, projectTrustReloadOptions } from "@/lib/project-trust";
|
||||
import {
|
||||
createAppSettingsManager,
|
||||
getAppResourceLoaderOptions,
|
||||
getManagedRuntimePaths,
|
||||
isManagedRuntime,
|
||||
} from "@/lib/app-runtime";
|
||||
|
||||
export async function loadSkillsWithInstallInfo(cwd: string): Promise<SkillsResponse> {
|
||||
const agentDir = getAgentDir();
|
||||
const loader = new DefaultResourceLoader({
|
||||
...getAppResourceLoaderOptions(),
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: createAppSettingsManager(cwd, agentDir),
|
||||
});
|
||||
await loader.reload(projectTrustReloadOptions(cwd, agentDir));
|
||||
const { skills, diagnostics } = loader.getSkills();
|
||||
const managedPaths = isManagedRuntime() ? getManagedRuntimePaths() : undefined;
|
||||
return {
|
||||
skills: annotateSkillsWithInstallInfo(skills as SkillInfo[], {
|
||||
cwd,
|
||||
agentDir,
|
||||
...(managedPaths ? { globalSkillRoots: managedPaths.managedSkillRoots } : {}),
|
||||
}),
|
||||
diagnostics,
|
||||
projectResourcesLoaded: getProjectTrustStatus(cwd, agentDir).trusted,
|
||||
};
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { execFile } from "child_process";
|
||||
import { existsSync, mkdirSync, realpathSync } from "fs";
|
||||
import { basename, dirname, join, resolve } from "path";
|
||||
import { promisify } from "util";
|
||||
import { allowFileRoot, normalizeSlashes } from "./allowed-roots";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ============================================================================
|
||||
// Project resolution: cwd → { projectRoot, branch }
|
||||
//
|
||||
// A worktree's `git rev-parse --git-common-dir` points at the *main* repo's
|
||||
// .git directory, so its parent is the project root shared by all worktrees.
|
||||
// Non-git directories resolve to themselves. Results are cached on globalThis
|
||||
// (hot-reload safe) with a short TTL; add/remove worktree invalidates eagerly.
|
||||
// ============================================================================
|
||||
|
||||
export interface ProjectInfo {
|
||||
projectRoot: string;
|
||||
/** Current branch of the cwd, null for non-git dirs or detached HEAD */
|
||||
branch: string | null;
|
||||
/** True when cwd is a linked worktree (not the main checkout) */
|
||||
isWorktree: boolean;
|
||||
/** True when cwd is the top-level directory of a checkout (main or linked).
|
||||
* False for repo subdirectories and non-git dirs — the worktree switcher
|
||||
* is only meaningful at the top level. */
|
||||
isTopLevel: boolean;
|
||||
}
|
||||
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string | null;
|
||||
isMain: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piProjectCache: Map<string, { info: ProjectInfo; expiresAt: number }> | undefined;
|
||||
}
|
||||
|
||||
const PROJECT_CACHE_TTL_MS = 60_000;
|
||||
|
||||
function getProjectCache(): Map<string, { info: ProjectInfo; expiresAt: number }> {
|
||||
if (!globalThis.__piProjectCache) globalThis.__piProjectCache = new Map();
|
||||
return globalThis.__piProjectCache;
|
||||
}
|
||||
|
||||
export function invalidateProjectCache(): void {
|
||||
globalThis.__piProjectCache?.clear();
|
||||
}
|
||||
|
||||
/** Path equality that tolerates Windows quirks: git prints forward slashes
|
||||
* while realpathSync uses backslashes, and drive letters may differ in case. */
|
||||
function samePath(a: string, b: string): boolean {
|
||||
if (process.platform === "win32") {
|
||||
return normalizeSlashes(a).toLowerCase() === normalizeSlashes(b).toLowerCase();
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
// Pin the message locale so error-text matching (e.g. the dirty-worktree
|
||||
// detection in the DELETE route) works regardless of system language.
|
||||
env: { ...process.env, LC_ALL: "C" },
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* addWorktree() places worktrees in `<repoRoot>-worktrees/<dir>`. When such a
|
||||
* directory no longer exists (worktree removed), group its sessions back
|
||||
* under the main repo instead of letting them dangle as a phantom project.
|
||||
* The dir name is the sanitized branch name — close enough for display.
|
||||
*/
|
||||
function inferRemovedWorktree(cwd: string): ProjectInfo | null {
|
||||
const parent = dirname(cwd);
|
||||
if (!parent.endsWith("-worktrees")) return null;
|
||||
const repoRoot = parent.slice(0, -"-worktrees".length);
|
||||
if (!repoRoot || !existsSync(join(repoRoot, ".git"))) return null;
|
||||
return { projectRoot: repoRoot, branch: basename(cwd), isWorktree: true, isTopLevel: true };
|
||||
}
|
||||
|
||||
export async function resolveProject(cwd: string): Promise<ProjectInfo> {
|
||||
const cache = getProjectCache();
|
||||
const cached = cache.get(cwd);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.info;
|
||||
|
||||
let info: ProjectInfo;
|
||||
try {
|
||||
if (!existsSync(cwd)) {
|
||||
info = inferRemovedWorktree(cwd) ?? { projectRoot: cwd, branch: null, isWorktree: false, isTopLevel: false };
|
||||
cache.set(cwd, { info, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
|
||||
return info;
|
||||
}
|
||||
const out = await git(cwd, [
|
||||
"rev-parse", "--path-format=absolute",
|
||||
"--git-common-dir", "--git-dir", "--show-toplevel",
|
||||
"--abbrev-ref", "HEAD",
|
||||
]);
|
||||
const [commonDir, gitDir, toplevel, ref] = out.split("\n").map((l) => l.trim());
|
||||
// git prints resolved (symlink-free) paths; normalize cwd the same way
|
||||
let realCwd = cwd;
|
||||
try { realCwd = realpathSync(cwd); } catch { /* keep as-is */ }
|
||||
// For a linked worktree, --git-dir differs from --git-common-dir.
|
||||
// Only collapse *worktree toplevels* into the main repo. A session whose
|
||||
// cwd is a subdirectory of a repo keeps its own project identity —
|
||||
// grouping subdirs under the repo root would change where new sessions
|
||||
// are created for existing users.
|
||||
const isTopLevel = samePath(toplevel, realCwd);
|
||||
const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
|
||||
info = {
|
||||
projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
|
||||
branch: ref && ref !== "HEAD" ? ref : null,
|
||||
isWorktree: isWorktreeTopLevel,
|
||||
isTopLevel,
|
||||
};
|
||||
} catch {
|
||||
info = { projectRoot: cwd, branch: null, isWorktree: false, isTopLevel: false };
|
||||
}
|
||||
|
||||
cache.set(cwd, { info, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
|
||||
return info;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree operations
|
||||
//
|
||||
// These take any directory inside the repo (a worktree, the main checkout, or
|
||||
// a subdirectory) and resolve the main repo root themselves via the git
|
||||
// common dir, so callers can pass session cwds directly.
|
||||
// ============================================================================
|
||||
|
||||
/** Main repo root (parent of the shared .git dir), or throws for non-git dirs */
|
||||
async function getRepoRoot(cwd: string): Promise<string> {
|
||||
const commonDir = await git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
||||
return dirname(commonDir);
|
||||
}
|
||||
|
||||
export async function listWorktrees(cwd: string): Promise<WorktreeInfo[]> {
|
||||
const out = await git(cwd, ["worktree", "list", "--porcelain"]);
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
let current: (Partial<WorktreeInfo> & { prunable?: boolean }) | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (current?.path) {
|
||||
// Prunable worktrees point at missing/broken gitdirs and cannot be
|
||||
// browsed or selected usefully. Also skip vanished paths even if git has
|
||||
// not marked them prunable yet.
|
||||
if (!current.prunable && existsSync(current.path)) {
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch ?? null,
|
||||
isMain: worktrees.length === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
current = null;
|
||||
};
|
||||
|
||||
for (const line of out.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
flush();
|
||||
current = { path: line.slice("worktree ".length).trim() };
|
||||
} else if (line.startsWith("branch ") && current) {
|
||||
current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
|
||||
} else if (line.startsWith("prunable") && current) {
|
||||
current.prunable = true;
|
||||
} else if (line.trim() === "") {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return worktrees;
|
||||
}
|
||||
|
||||
function sanitizeBranchForDir(branch: string): string {
|
||||
return branch.replace(/[\/\\:*?"<>|\s]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export async function addWorktree(cwd: string, branch: string): Promise<{ path: string; branch: string }> {
|
||||
const trimmed = branch.trim();
|
||||
if (!trimmed) throw new Error("Branch name is required");
|
||||
|
||||
const dirName = sanitizeBranchForDir(trimmed);
|
||||
if (!dirName) throw new Error(`Invalid branch name: ${branch}`);
|
||||
|
||||
const repoRoot = await getRepoRoot(cwd);
|
||||
const baseDir = `${resolve(repoRoot)}-worktrees`;
|
||||
const worktreePath = join(baseDir, dirName);
|
||||
if (existsSync(worktreePath)) {
|
||||
throw new Error(`Directory already exists: ${worktreePath}`);
|
||||
}
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
|
||||
// Reuse the branch if it already exists, otherwise create it at HEAD.
|
||||
let branchExists = false;
|
||||
try {
|
||||
await git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${trimmed}`]);
|
||||
branchExists = true;
|
||||
} catch {
|
||||
branchExists = false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (branchExists) {
|
||||
await git(repoRoot, ["worktree", "add", "--", worktreePath, trimmed]);
|
||||
} else {
|
||||
await git(repoRoot, ["worktree", "add", "-b", trimmed, "--", worktreePath]);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(extractGitError(error));
|
||||
}
|
||||
|
||||
allowFileRoot(worktreePath);
|
||||
invalidateProjectCache();
|
||||
return { path: worktreePath, branch: trimmed };
|
||||
}
|
||||
|
||||
export async function removeWorktree(cwd: string, worktreePath: string, force = false): Promise<void> {
|
||||
const worktrees = await listWorktrees(cwd);
|
||||
const target = worktrees.find((w) => w.path === worktreePath);
|
||||
if (!target) throw new Error(`Not a worktree of this repository: ${worktreePath}`);
|
||||
if (target.isMain) throw new Error("Cannot remove the main worktree");
|
||||
|
||||
try {
|
||||
await git(cwd, ["worktree", "remove", ...(force ? ["--force"] : []), worktreePath]);
|
||||
} catch (error) {
|
||||
throw new Error(extractGitError(error));
|
||||
}
|
||||
invalidateProjectCache();
|
||||
}
|
||||
|
||||
function extractGitError(error: unknown): string {
|
||||
const stderr = (error as { stderr?: string }).stderr;
|
||||
if (typeof stderr === "string" && stderr.trim()) return stderr.trim();
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
Reference in New Issue
Block a user