feat(coding-agent): add extension project trust decisions

This commit is contained in:
Mario Zechner 2026-06-08 12:58:49 +02:00
parent dce3e28516
commit 718215bd95
18 changed files with 631 additions and 86 deletions

View file

@ -4,6 +4,7 @@
### Added
- Added a `project_trust` extension event so global and CLI extensions can decide project trust during startup and runtime cwd switches.
- Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)).
- Added the latest prompt cache hit rate to the interactive footer.
- Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)).

View file

@ -293,6 +293,8 @@ See [docs/settings.md](docs/settings.md) for all options.
On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
Before the trust decision, pi loads only user/global extensions and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, project settings, and project instructions are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted.
`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`.

View file

@ -270,6 +270,7 @@ Run `npm install` in the extension directory, then imports from `node_modules/`
```
pi starts
├─► project_trust (user/global and CLI extensions only, before project resources load)
├─► session_start { reason: "startup" }
└─► resources_discover { reason: "startup" }
@ -334,6 +335,24 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
└─► session_shutdown
```
### Startup Events
#### project_trust
Fired before pi decides whether to trust a project with trust inputs (`.pi`, `AGENTS.md`/`CLAUDE.md`, or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved.
```typescript
pi.on("project_trust", async (event, ctx) => {
// event.cwd - current working directory
// ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers
if (await ctx.ui.confirm("Trust project?", event.cwd)) {
return { trusted: true, remember: true };
}
});
```
A `project_trust` handler must return `{ trusted }`; if a user/global or CLI extension registers this handler, it owns the decision. The first returned decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist the decision; otherwise it applies only to the current process. Check `ctx.hasUI` before prompting. If no extension handles `project_trust`, normal trust resolution continues, including the built-in trust prompt when UI is available.
### Resource Events
#### resources_discover
@ -2567,6 +2586,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` |
| **Events & Gates** |||
| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` |
| `project-trust.ts` | Decide project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result |
| `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` |
| `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` |
| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |

View file

@ -19,6 +19,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| Extension | Description |
|-----------|-------------|
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
| `project-trust.ts` | Demonstrates the `project_trust` event for user/global and CLI extensions |
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |

View file

@ -0,0 +1,59 @@
/**
* Project Trust Extension
*
* Demonstrates the project_trust event. Install globally or pass via -e:
*
* mkdir -p ~/.pi/agent/extensions
* cp packages/coding-agent/examples/extensions/project-trust.ts ~/.pi/agent/extensions/
*
* Or:
*
* pi -e packages/coding-agent/examples/extensions/project-trust.ts
*
* Try it in a project containing .pi, AGENTS.md/CLAUDE.md, or .agents/skills.
*/
import type { ExtensionAPI, ProjectTrustEventResult } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
let loadCount = 0;
loadCount++;
// Multiple handlers in one extension are allowed. The first handler that returns
// { trusted } wins and suppresses the built-in trust prompt. A project_trust
// handler must return a decision.
pi.on("project_trust", async (event, ctx): Promise<ProjectTrustEventResult> => {
ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info");
if (!ctx.hasUI) {
return { trusted: false };
}
const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [
"Trust and remember",
"Trust with note and remember",
"Trust this session",
"Do not trust this session",
]);
if (choice === "Trust with note and remember") {
const note = await ctx.ui.input("Project trust note", "Optional note for this demo");
ctx.ui.notify(note ? `Recorded demo note: ${note}` : "No demo note entered", "info");
return { trusted: true, remember: true };
}
if (choice === "Trust and remember") {
return { trusted: true, remember: true };
}
if (choice === "Trust this session") {
return { trusted: true };
}
if (choice === "Do not trust this session") {
return { trusted: false };
}
return { trusted: false };
});
pi.on("session_start", (_event, ctx) => {
ctx.ui.notify(`project-trust example loaded after trust resolution in ${ctx.cwd}`, "info");
});
}

View file

@ -3,7 +3,12 @@ import { basename, join, resolve } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
import type {
ProjectTrustContext,
ReplacedSessionContext,
SessionShutdownEvent,
SessionStartEvent,
} from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { CreateAgentSessionResult } from "./sdk.ts";
import { assertSessionCwdExists } from "./session-cwd.ts";
@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: {
agentDir: string;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;
/**
@ -186,7 +192,11 @@ export class AgentSessionRuntime {
async switchSession(
sessionPath: string,
options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
options?: {
cwdOverride?: string;
withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;
},
): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
if (beforeResult.cancelled) {
@ -203,6 +213,7 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
}),
);
await this.finishSessionReplacement(options?.withSession);

View file

@ -6,7 +6,12 @@ import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts";
import {
DefaultResourceLoader,
type DefaultResourceLoaderOptions,
type ResourceLoader,
type ResourceLoaderReloadOptions,
} from "./resource-loader.ts";
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
import type { SessionManager } from "./session-manager.ts";
import { SettingsManager } from "./settings-manager.ts";
@ -38,6 +43,7 @@ export interface CreateAgentSessionServicesOptions {
modelRegistry?: ModelRegistry;
extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
}
/**
@ -142,7 +148,7 @@ export async function createAgentSessionServices(
agentDir,
settingsManager,
});
await resourceLoader.reload();
await resourceLoader.reload(options.resourceLoaderReloadOptions);
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();

View file

@ -98,6 +98,10 @@ export type {
MessageUpdateEvent,
ModelSelectEvent,
ModelSelectSource,
ProjectTrustContext,
ProjectTrustEvent,
ProjectTrustEventResult,
ProjectTrustHandler,
// Provider Registration
ProviderConfig,
ProviderModelConfig,

View file

@ -410,15 +410,20 @@ export async function loadExtensionFromFactory(
/**
* Load extensions from paths.
*/
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
export async function loadExtensions(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
const resolvedRuntime = runtime ?? createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime);
if (error) {
errors.push({ path: extPath, error });
@ -433,7 +438,7 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev
return {
extensions,
errors,
runtime,
runtime: resolvedRuntime,
};
}

View file

@ -35,9 +35,13 @@ import type {
InputEvent,
InputEventResult,
InputSource,
LoadExtensionsResult,
MessageEndEvent,
MessageEndEventResult,
MessageRenderer,
ProjectTrustContext,
ProjectTrustEvent,
ProjectTrustEventResult,
ProviderConfig,
RegisteredCommand,
RegisteredTool,
@ -116,6 +120,7 @@ interface BeforeAgentStartCombinedResult {
type RunnerEmitEvent = Exclude<
ExtensionEvent,
| ToolCallEvent
| ProjectTrustEvent
| ToolResultEvent
| UserBashEvent
| ContextEvent
@ -189,6 +194,35 @@ export async function emitSessionShutdownEvent(
return false;
}
export async function emitProjectTrustEvent(
extensionsResult: LoadExtensionsResult,
event: ProjectTrustEvent,
ctx: ProjectTrustContext,
): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> {
const errors: ExtensionError[] = [];
for (const ext of extensionsResult.extensions) {
// A single extension may register multiple handlers for the same event.
// The first project_trust handler that returns a decision wins.
const handlers = ext.handlers.get("project_trust");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult;
return { result: handlerResult, errors };
} catch (error) {
errors.push({
extensionPath: ext.path,
event: event.type,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
}
}
return { errors };
}
const noOpUIContext: ExtensionUIContext = {
select: async () => undefined,
confirm: async () => false,

View file

@ -495,9 +495,31 @@ export function defineTool<TParams extends TSchema, TDetails = unknown, TState =
}
// ============================================================================
// Resource Events
// Startup/Resource Events
// ============================================================================
export interface ProjectTrustEvent {
type: "project_trust";
cwd: string;
}
export interface ProjectTrustEventResult {
trusted: boolean;
remember?: boolean;
}
export interface ProjectTrustContext {
cwd: string;
mode: ExtensionMode;
hasUI: boolean;
ui: Pick<ExtensionUIContext, "select" | "confirm" | "input" | "notify">;
}
export type ProjectTrustHandler = (
event: ProjectTrustEvent,
ctx: ProjectTrustContext,
) => Promise<ProjectTrustEventResult> | ProjectTrustEventResult;
/** Fired after session_start to allow extensions to provide additional resource paths. */
export interface ResourcesDiscoverEvent {
type: "resources_discover";
@ -957,6 +979,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
/** Union of all event types */
export type ExtensionEvent =
| ProjectTrustEvent
| ResourcesDiscoverEvent
| SessionEvent
| ContextEvent
@ -1095,6 +1118,7 @@ export interface ExtensionAPI {
// Event Subscription
// =========================================================================
on(event: "project_trust", handler: ProjectTrustHandler): void;
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(

View file

@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts";
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
import { loadPromptTemplates } from "./prompt-templates.ts";
import { SettingsManager } from "./settings-manager.ts";
@ -25,6 +25,10 @@ export interface ResourceExtensionPaths {
themePaths?: Array<{ path: string; metadata: PathMetadata }>;
}
export interface ResourceLoaderReloadOptions {
resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;
}
export interface ResourceLoader {
getExtensions(): LoadExtensionsResult;
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
@ -34,7 +38,7 @@ export interface ResourceLoader {
getSystemPrompt(): string | undefined;
getAppendSystemPrompt(): string[];
extendResources(paths: ResourceExtensionPaths): void;
reload(): Promise<void>;
reload(options?: ResourceLoaderReloadOptions): Promise<void>;
}
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
@ -321,7 +325,19 @@ export class DefaultResourceLoader implements ResourceLoader {
}
}
async reload(): Promise<void> {
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) {
// Force untrusted project settings for the bootstrap pass. This keeps project-local
// extensions/packages out while still loading user/global and temporary CLI extensions.
this.settingsManager.setProjectTrusted(false);
await this.settingsManager.reload();
preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true });
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
this.settingsManager.setProjectTrusted(projectTrusted);
}
// reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state.
await this.settingsManager.reload();
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
@ -334,9 +350,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.extensionThemeSourceInfos = new Map();
// Helper to extract enabled paths and store metadata
const getEnabledResources = (
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {
const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => {
for (const r of resources) {
if (!metadataByPath.has(r.path)) {
metadataByPath.set(r.path, r.metadata);
@ -345,37 +359,14 @@ export class DefaultResourceLoader implements ResourceLoader {
return resources.filter((r) => r.enabled);
};
const getEnabledPaths = (
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
): string[] => getEnabledResources(resources).map((r) => r.path);
const getEnabledPaths = (resources: ResolvedResource[]): string[] =>
getEnabledResources(resources).map((r) => r.path);
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);
const enabledSkillResources = getEnabledResources(resolvedPaths.skills);
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);
const enabledThemes = getEnabledPaths(resolvedPaths.themes);
const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => {
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
return resource.path;
}
try {
const stats = statSync(resource.path);
if (!stats.isDirectory()) {
return resource.path;
}
} catch {
return resource.path;
}
const skillFile = join(resource.path, "SKILL.md");
if (existsSync(skillFile)) {
if (!metadataByPath.has(skillFile)) {
metadataByPath.set(skillFile, resource.metadata);
}
return skillFile;
}
return resource.path;
};
const enabledSkills = enabledSkillResources.map(mapSkillPath);
const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));
// Add CLI paths metadata
for (const r of cliExtensionPaths.extensions) {
@ -398,18 +389,7 @@ export class DefaultResourceLoader implements ResourceLoader {
? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
for (const conflict of conflicts) {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions);
for (const p of this.additionalExtensionPaths) {
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
@ -497,6 +477,115 @@ export class DefaultResourceLoader implements ResourceLoader {
: baseAppend;
}
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true,
});
const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
const extensionPaths = this.noExtensions
? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
if (!options.includeInlineFactories) {
return extensionsResult;
}
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
return extensionsResult;
}
private resolveExtensionLoadPath(path: string): string {
return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
}
private async loadFinalExtensionSet(
extensionPaths: string[],
preTrustExtensions: LoadExtensionsResult | undefined,
): Promise<LoadExtensionsResult> {
if (!preTrustExtensions) {
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
this.addExtensionConflictDiagnostics(extensionsResult);
return extensionsResult;
}
const preloadedByPath = new Map(
preTrustExtensions.extensions
.filter((extension) => !extension.path.startsWith("<inline:"))
.map((extension) => [extension.resolvedPath, extension]),
);
const failedPreloadPaths = new Set(
preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)),
);
const remainingPaths = extensionPaths.filter((path) => {
const resolvedPath = this.resolveExtensionLoadPath(path);
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
});
const remainingExtensions = await loadExtensions(
remainingPaths,
this.cwd,
this.eventBus,
preTrustExtensions.runtime,
);
const loadedByPath = new Map(preloadedByPath);
for (const extension of remainingExtensions.extensions) {
loadedByPath.set(extension.resolvedPath, extension);
}
const inlineExtensions = preTrustExtensions.extensions.filter((extension) =>
extension.path.startsWith("<inline:"),
);
const orderedExtensions = extensionPaths
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
.filter((extension): extension is Extension => extension !== undefined);
orderedExtensions.push(...inlineExtensions);
const extensionsResult: LoadExtensionsResult = {
extensions: orderedExtensions,
errors: [...preTrustExtensions.errors, ...remainingExtensions.errors],
runtime: preTrustExtensions.runtime,
};
this.addExtensionConflictDiagnostics(extensionsResult);
return extensionsResult;
}
private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void {
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
for (const conflict of conflicts) {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
}
private mapSkillPath(resource: ResolvedResource, metadataByPath: Map<string, PathMetadata>): string {
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
return resource.path;
}
try {
const stats = statSync(resource.path);
if (!stats.isDirectory()) {
return resource.path;
}
} catch {
return resource.path;
}
const skillFile = join(resource.path, "SKILL.md");
if (existsSync(skillFile)) {
if (!metadataByPath.has(skillFile)) {
metadataByPath.set(skillFile, resource.metadata);
}
return skillFile;
}
return resource.path;
}
private normalizeExtensionPaths(
entries: Array<{ path: string; metadata: PathMetadata }>,
): Array<{ path: string; metadata: PathMetadata }> {

View file

@ -98,6 +98,10 @@ export type {
LsToolCallEvent,
MessageRenderer,
MessageRenderOptions,
ProjectTrustContext,
ProjectTrustEvent,
ProjectTrustEventResult,
ProjectTrustHandler,
ProviderConfig,
ProviderModelConfig,
ReadToolCallEvent,

View file

@ -24,7 +24,8 @@ import {
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
import { emitProjectTrustEvent } from "./core/extensions/runner.ts";
import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { KeybindingsManager } from "./core/keybindings.ts";
import type { ModelRegistry } from "./core/model-registry.ts";
@ -43,6 +44,7 @@ import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
@ -437,24 +439,35 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
}
function createStartupTui(settingsManager: SettingsManager): TUI {
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
ui.setClearOnShrink(settingsManager.getClearOnShrink());
return ui;
}
async function clearStartupTui(ui: TUI): Promise<void> {
ui.clear();
ui.requestRender();
await new Promise((resolve) => setTimeout(resolve, 25));
}
async function showStartupSelector<T>(
settingsManager: SettingsManager,
title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
ui.setClearOnShrink(settingsManager.getClearOnShrink());
const ui = createStartupTui(settingsManager);
let settled = false;
const finish = (result: T | undefined) => {
const finish = async (result: T | undefined) => {
if (settled) {
return;
}
settled = true;
await clearStartupTui(ui);
ui.stop();
resolve(result);
};
@ -462,8 +475,8 @@ async function showStartupSelector<T>(
const selector = new ExtensionSelectorComponent(
title,
options.map((option) => option.label),
(option) => finish(options.find((entry) => entry.label === option)?.value),
() => finish(undefined),
(option) => void finish(options.find((entry) => entry.label === option)?.value),
() => void finish(undefined),
{ tui: ui },
);
ui.addChild(selector);
@ -472,6 +485,41 @@ async function showStartupSelector<T>(
});
}
async function showStartupInput(
settingsManager: SettingsManager,
title: string,
placeholder?: string,
): Promise<string | undefined> {
return new Promise((resolve) => {
const ui = createStartupTui(settingsManager);
let settled = false;
const finish = async (result: string | undefined) => {
if (settled) {
return;
}
settled = true;
input.dispose();
await clearStartupTui(ui);
ui.stop();
resolve(result);
};
const input = new ExtensionInputComponent(
title,
placeholder,
(value) => void finish(value),
() => void finish(undefined),
{
tui: ui,
},
);
ui.addChild(input);
ui.setFocus(input);
ui.start();
});
}
async function promptForMissingSessionCwd(
issue: SessionCwdIssue,
settingsManager: SettingsManager,
@ -487,20 +535,90 @@ interface ProjectTrustPromptResult {
remember: boolean;
}
const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [
{ label: "Trust", value: { trusted: true, remember: true } },
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
{ label: "Do not trust", value: { trusted: false, remember: true } },
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
];
function formatProjectTrustPrompt(cwd: string): string {
return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`;
}
async function promptForProjectTrust(
cwd: string,
settingsManager: SettingsManager,
): Promise<ProjectTrustPromptResult | undefined> {
return showStartupSelector(
settingsManager,
`Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`,
[
{ label: "Trust", value: { trusted: true, remember: true } },
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
{ label: "Do not trust", value: { trusted: false, remember: true } },
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
],
return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS);
}
async function promptForProjectTrustWithContext(
cwd: string,
ctx: ProjectTrustContext,
): Promise<ProjectTrustPromptResult | undefined> {
const selected = await ctx.ui.select(
formatProjectTrustPrompt(cwd),
PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label),
);
return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value;
}
function createProjectTrustContext(options: {
cwd: string;
mode: AppMode;
settingsManager: SettingsManager;
hasUI: boolean;
}): ProjectTrustContext {
return {
cwd: options.cwd,
mode: options.mode === "interactive" ? "tui" : options.mode,
hasUI: options.hasUI,
ui: {
select: async (title, selectOptions) => {
if (!options.hasUI) {
return undefined;
}
if (options.mode !== "interactive") {
return undefined;
}
return showStartupSelector(
options.settingsManager,
title,
selectOptions.map((option) => ({ label: option, value: option })),
);
},
confirm: async (title, message) => {
if (!options.hasUI) {
return false;
}
if (options.mode !== "interactive") {
return false;
}
return (
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
{ label: "Yes", value: true },
{ label: "No", value: false },
])) ?? false
);
},
input: async (title, placeholder) => {
if (!options.hasUI) {
return undefined;
}
if (options.mode !== "interactive") {
return undefined;
}
return showStartupInput(options.settingsManager, title, placeholder);
},
notify: (message, type = "info") => {
if (options.mode !== "interactive") {
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
console.error(color(message));
}
},
},
};
}
async function resolveProjectTrusted(options: {
@ -509,6 +627,9 @@ async function resolveProjectTrusted(options: {
trustOverride?: boolean;
appMode: AppMode;
settingsManagerForPrompt: SettingsManager;
extensionsResult?: LoadExtensionsResult;
projectTrustContext?: ProjectTrustContext;
onExtensionError?: (message: string) => void;
}): Promise<boolean> {
if (options.trustOverride !== undefined) {
return options.trustOverride;
@ -517,10 +638,37 @@ async function resolveProjectTrusted(options: {
return true;
}
if (options.extensionsResult && options.projectTrustContext) {
const { result, errors } = await emitProjectTrustEvent(
options.extensionsResult,
{ type: "project_trust", cwd: options.cwd },
options.projectTrustContext,
);
for (const error of errors) {
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
}
if (result) {
if (result.remember === true) {
options.trustStore.set(options.cwd, result.trusted);
}
return result.trusted;
}
}
const decision = options.trustStore.get(options.cwd);
if (decision !== null) {
return decision;
}
if (options.projectTrustContext?.hasUI) {
const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext);
if (selected !== undefined) {
if (selected.remember) {
options.trustStore.set(options.cwd, selected.trusted);
}
return selected.trusted;
}
return false;
}
if (options.appMode !== "interactive") {
return false;
}
@ -651,13 +799,7 @@ export async function main(args: string[], options?: MainOptions) {
const autoTrustOnReloadCwd =
parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined;
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
const projectTrustedForSession = await resolveProjectTrusted({
cwd: sessionCwd,
trustStore,
trustOverride: parsed.projectTrustOverride,
appMode: trustPromptMode,
settingsManagerForPrompt: startupSettingsManager,
});
const projectTrustByCwd = new Map<string, boolean>();
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
@ -669,11 +811,17 @@ export async function main(args: string[], options?: MainOptions) {
agentDir,
sessionManager,
sessionStartEvent,
projectTrustContext,
}) => {
const projectTrusted =
cwd === sessionManager.getCwd()
? projectTrustedForSession
: (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true));
const isInitialRuntime = sessionStartEvent === undefined;
const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];
const cachedProjectTrust = projectTrustByCwd.get(cwd);
const hasTrustInputs = hasProjectTrustInputs(cwd);
const shouldResolveProjectTrust =
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs;
const projectTrusted = shouldResolveProjectTrust
? false
: (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true));
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
const services = await createAgentSessionServices({
cwd,
@ -681,6 +829,31 @@ export async function main(args: string[], options?: MainOptions) {
authStorage,
settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags,
resourceLoaderReloadOptions: shouldResolveProjectTrust
? {
resolveProjectTrust: async ({ extensionsResult }) => {
const trusted = await resolveProjectTrusted({
cwd,
trustStore,
trustOverride: parsed.projectTrustOverride,
appMode: isInitialRuntime ? trustPromptMode : "print",
settingsManagerForPrompt: startupSettingsManager,
extensionsResult,
projectTrustContext:
projectTrustContext ??
createProjectTrustContext({
cwd,
mode: isInitialRuntime ? trustPromptMode : appMode,
settingsManager: startupSettingsManager,
hasUI: isInitialRuntime && trustPromptMode === "interactive",
}),
onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
});
projectTrustByCwd.set(cwd, trusted);
return trusted;
},
}
: undefined,
resourceLoaderOptions: {
additionalExtensionPaths: resolvedExtensionPaths,
additionalSkillPaths: resolvedSkillPaths,
@ -698,6 +871,7 @@ export async function main(args: string[], options?: MainOptions) {
});
const { settingsManager, modelRegistry, resourceLoader } = services;
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
...projectTrustDiagnostics,
...services.diagnostics,
...collectSettingsDiagnostics(settingsManager, "runtime creation"),
...resourceLoader.getExtensions().errors.map(({ path, error }) => ({

View file

@ -70,6 +70,7 @@ import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
ProjectTrustContext,
} from "../../core/extensions/index.ts";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
@ -1993,6 +1994,21 @@ export class InteractiveMode {
/**
* Create the ExtensionUIContext for extensions.
*/
private createProjectTrustContext(cwd: string): ProjectTrustContext {
const ui = this.createExtensionUIContext();
return {
cwd,
mode: "tui",
hasUI: true,
ui: {
select: ui.select,
confirm: ui.confirm,
input: ui.input,
notify: ui.notify,
},
};
}
private createExtensionUIContext(): ExtensionUIContext {
return {
select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
@ -4569,6 +4585,7 @@ export class InteractiveMode {
try {
const result = await this.runtimeHost.switchSession(sessionPath, {
withSession: options?.withSession,
projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd),
});
if (result.cancelled) {
return result;
@ -4586,6 +4603,7 @@ export class InteractiveMode {
const result = await this.runtimeHost.switchSession(sessionPath, {
cwdOverride: selectedCwd,
withSession: options?.withSession,
projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd),
});
if (result.cancelled) {
return result;

View file

@ -187,6 +187,53 @@ Project skill`,
expect(extensionsResult.extensions[0].path).toBe(join(cwd, ".pi", "extensions", "shared.ts"));
});
it("should load user extensions before trust and reuse them after trust resolves", async () => {
const userExtDir = join(agentDir, "extensions");
const projectExtDir = join(cwd, ".pi", "extensions");
mkdirSync(userExtDir, { recursive: true });
mkdirSync(projectExtDir, { recursive: true });
const loadCountKey = `__piTrustPreloadCount_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const globalState = globalThis as typeof globalThis & Record<string, number | undefined>;
writeFileSync(
join(userExtDir, "user.ts"),
`globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1;
export default function(pi) {
pi.on("project_trust", () => ({ trusted: true }));
pi.registerCommand("user-trust", {
description: "user trust",
handler: async () => {},
});
}`,
);
writeFileSync(
join(projectExtDir, "project.ts"),
`export default function(pi) {
pi.registerCommand("project-trusted", {
description: "project trusted",
handler: async () => {},
});
}`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload({
resolveProjectTrust: async ({ extensionsResult }) => {
expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([
join(userExtDir, "user.ts"),
]);
return true;
},
});
const extensionsResult = loader.getExtensions();
expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([
join(cwd, ".pi", "extensions", "project.ts"),
join(userExtDir, "user.ts"),
]);
expect(globalState[loadCountKey]).toBe(1);
});
it("should keep both extensions loaded when command names collide", async () => {
const userExtDir = join(agentDir, "extensions");
const projectExtDir = join(cwd, ".pi", "extensions");

View file

@ -1282,15 +1282,17 @@ export class TUI extends Container {
fullRender(true);
return;
}
if (extraLines > 0) {
buffer += "\x1b[1B";
const clearStartOffset = newLines.length === 0 ? 0 : 1;
if (extraLines > 0 && clearStartOffset > 0) {
buffer += `\x1b[${clearStartOffset}B`;
}
for (let i = 0; i < extraLines; i++) {
buffer += "\r\x1b[2K";
if (i < extraLines - 1) buffer += "\x1b[1B";
}
if (extraLines > 0) {
buffer += `\x1b[${extraLines}A`;
const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
if (moveBack > 0) {
buffer += `\x1b[${moveBack}A`;
}
buffer += "\x1b[?2026l";
this.terminal.write(buffer);

View file

@ -0,0 +1,44 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { type Component, TUI } from "../src/tui.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
class Lines implements Component {
private lines: string[];
constructor(lines: string[]) {
this.lines = lines;
}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
describe("TUI shrinking content", () => {
it("clears all rendered lines when content shrinks to zero", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const content = new Lines(["first", "second", "third"]);
tui.addChild(content);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.getViewport().some((line) => line.includes("first")));
assert.ok(terminal.getViewport().some((line) => line.includes("second")));
assert.ok(terminal.getViewport().some((line) => line.includes("third")));
tui.clear();
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared");
assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared");
assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared");
tui.stop();
});
});