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
- 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 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. - 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)). - 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. 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. 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`. `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 pi starts
├─► project_trust (user/global and CLI extensions only, before project resources load)
├─► session_start { reason: "startup" } ├─► session_start { reason: "startup" }
└─► resources_discover { reason: "startup" } └─► resources_discover { reason: "startup" }
@@ -334,6 +335,24 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
└─► session_shutdown └─► 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 ### Resource Events
#### resources_discover #### resources_discover
@@ -2567,6 +2586,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | | `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` |
| **Events & Gates** ||| | **Events & Gates** |||
| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | | `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")` | | `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")` | | `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` | | `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 | | Extension | Description |
|-----------|-------------| |-----------|-------------|
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | | `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/) | | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) | | `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | | `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 { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts"; import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.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 { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { CreateAgentSessionResult } from "./sdk.ts"; import type { CreateAgentSessionResult } from "./sdk.ts";
import { assertSessionCwdExists } from "./session-cwd.ts"; import { assertSessionCwdExists } from "./session-cwd.ts";
@@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: {
agentDir: string; agentDir: string;
sessionManager: SessionManager; sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent; sessionStartEvent?: SessionStartEvent;
projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>; }) => Promise<CreateAgentSessionRuntimeResult>;
/** /**
@@ -186,7 +192,11 @@ export class AgentSessionRuntime {
async switchSession( async switchSession(
sessionPath: string, 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 }> { ): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
if (beforeResult.cancelled) { if (beforeResult.cancelled) {
@@ -203,6 +213,7 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir, agentDir: this.services.agentDir,
sessionManager, sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
}), }),
); );
await this.finishSessionReplacement(options?.withSession); await this.finishSessionReplacement(options?.withSession);

View File

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

View File

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

View File

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

View File

@@ -35,9 +35,13 @@ import type {
InputEvent, InputEvent,
InputEventResult, InputEventResult,
InputSource, InputSource,
LoadExtensionsResult,
MessageEndEvent, MessageEndEvent,
MessageEndEventResult, MessageEndEventResult,
MessageRenderer, MessageRenderer,
ProjectTrustContext,
ProjectTrustEvent,
ProjectTrustEventResult,
ProviderConfig, ProviderConfig,
RegisteredCommand, RegisteredCommand,
RegisteredTool, RegisteredTool,
@@ -116,6 +120,7 @@ interface BeforeAgentStartCombinedResult {
type RunnerEmitEvent = Exclude< type RunnerEmitEvent = Exclude<
ExtensionEvent, ExtensionEvent,
| ToolCallEvent | ToolCallEvent
| ProjectTrustEvent
| ToolResultEvent | ToolResultEvent
| UserBashEvent | UserBashEvent
| ContextEvent | ContextEvent
@@ -189,6 +194,35 @@ export async function emitSessionShutdownEvent(
return false; 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 = { const noOpUIContext: ExtensionUIContext = {
select: async () => undefined, select: async () => undefined,
confirm: async () => false, 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. */ /** Fired after session_start to allow extensions to provide additional resource paths. */
export interface ResourcesDiscoverEvent { export interface ResourcesDiscoverEvent {
type: "resources_discover"; type: "resources_discover";
@@ -957,6 +979,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
/** Union of all event types */ /** Union of all event types */
export type ExtensionEvent = export type ExtensionEvent =
| ProjectTrustEvent
| ResourcesDiscoverEvent | ResourcesDiscoverEvent
| SessionEvent | SessionEvent
| ContextEvent | ContextEvent
@@ -1095,6 +1118,7 @@ export interface ExtensionAPI {
// Event Subscription // Event Subscription
// ========================================================================= // =========================================================================
on(event: "project_trust", handler: ProjectTrustHandler): void;
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void; on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void; on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on( on(

View File

@@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts"; import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.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 type { PromptTemplate } from "./prompt-templates.ts";
import { loadPromptTemplates } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts";
import { SettingsManager } from "./settings-manager.ts"; import { SettingsManager } from "./settings-manager.ts";
@@ -25,6 +25,10 @@ export interface ResourceExtensionPaths {
themePaths?: Array<{ path: string; metadata: PathMetadata }>; themePaths?: Array<{ path: string; metadata: PathMetadata }>;
} }
export interface ResourceLoaderReloadOptions {
resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;
}
export interface ResourceLoader { export interface ResourceLoader {
getExtensions(): LoadExtensionsResult; getExtensions(): LoadExtensionsResult;
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
@@ -34,7 +38,7 @@ export interface ResourceLoader {
getSystemPrompt(): string | undefined; getSystemPrompt(): string | undefined;
getAppendSystemPrompt(): string[]; getAppendSystemPrompt(): string[];
extendResources(paths: ResourceExtensionPaths): void; extendResources(paths: ResourceExtensionPaths): void;
reload(): Promise<void>; reload(options?: ResourceLoaderReloadOptions): Promise<void>;
} }
function resolvePromptInput(input: string | undefined, description: string): string | undefined { 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(); await this.settingsManager.reload();
const resolvedPaths = await this.packageManager.resolve(); const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
@@ -334,9 +350,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.extensionThemeSourceInfos = new Map(); this.extensionThemeSourceInfos = new Map();
// Helper to extract enabled paths and store metadata // Helper to extract enabled paths and store metadata
const getEnabledResources = ( const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => {
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {
for (const r of resources) { for (const r of resources) {
if (!metadataByPath.has(r.path)) { if (!metadataByPath.has(r.path)) {
metadataByPath.set(r.path, r.metadata); metadataByPath.set(r.path, r.metadata);
@@ -345,37 +359,14 @@ export class DefaultResourceLoader implements ResourceLoader {
return resources.filter((r) => r.enabled); return resources.filter((r) => r.enabled);
}; };
const getEnabledPaths = ( const getEnabledPaths = (resources: ResolvedResource[]): string[] =>
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, getEnabledResources(resources).map((r) => r.path);
): string[] => getEnabledResources(resources).map((r) => r.path);
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);
const enabledSkillResources = getEnabledResources(resolvedPaths.skills); const enabledSkillResources = getEnabledResources(resolvedPaths.skills);
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);
const enabledThemes = getEnabledPaths(resolvedPaths.themes); const enabledThemes = getEnabledPaths(resolvedPaths.themes);
const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => { const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));
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);
// Add CLI paths metadata // Add CLI paths metadata
for (const r of cliExtensionPaths.extensions) { for (const r of cliExtensionPaths.extensions) {
@@ -398,18 +389,7 @@ export class DefaultResourceLoader implements ResourceLoader {
? cliEnabledExtensions ? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions); : this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions);
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 });
}
for (const p of this.additionalExtensionPaths) { for (const p of this.additionalExtensionPaths) {
if (isLocalPath(p)) { if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p); const resolved = this.resolveResourcePath(p);
@@ -497,6 +477,115 @@ export class DefaultResourceLoader implements ResourceLoader {
: baseAppend; : 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( private normalizeExtensionPaths(
entries: Array<{ path: string; metadata: PathMetadata }>, entries: Array<{ path: string; metadata: PathMetadata }>,
): Array<{ path: string; metadata: PathMetadata }> { ): Array<{ path: string; metadata: PathMetadata }> {

View File

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

View File

@@ -24,7 +24,8 @@ import {
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts"; import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.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 { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { KeybindingsManager } from "./core/keybindings.ts"; import { KeybindingsManager } from "./core/keybindings.ts";
import type { ModelRegistry } from "./core/model-registry.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 { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.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 { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.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)); 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>( async function showStartupSelector<T>(
settingsManager: SettingsManager, settingsManager: SettingsManager,
title: string, title: string,
options: Array<{ label: string; value: T }>, options: Array<{ label: string; value: T }>,
): Promise<T | undefined> { ): Promise<T | undefined> {
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
return new Promise((resolve) => { return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); const ui = createStartupTui(settingsManager);
ui.setClearOnShrink(settingsManager.getClearOnShrink());
let settled = false; let settled = false;
const finish = (result: T | undefined) => { const finish = async (result: T | undefined) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
await clearStartupTui(ui);
ui.stop(); ui.stop();
resolve(result); resolve(result);
}; };
@@ -462,8 +475,8 @@ async function showStartupSelector<T>(
const selector = new ExtensionSelectorComponent( const selector = new ExtensionSelectorComponent(
title, title,
options.map((option) => option.label), options.map((option) => option.label),
(option) => finish(options.find((entry) => entry.label === option)?.value), (option) => void finish(options.find((entry) => entry.label === option)?.value),
() => finish(undefined), () => void finish(undefined),
{ tui: ui }, { tui: ui },
); );
ui.addChild(selector); 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( async function promptForMissingSessionCwd(
issue: SessionCwdIssue, issue: SessionCwdIssue,
settingsManager: SettingsManager, settingsManager: SettingsManager,
@@ -487,20 +535,90 @@ interface ProjectTrustPromptResult {
remember: boolean; remember: boolean;
} }
async function promptForProjectTrust( const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [
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", value: { trusted: true, remember: true } },
{ label: "Trust (this session only)", value: { trusted: true, remember: false } }, { label: "Trust (this session only)", value: { trusted: true, remember: false } },
{ label: "Do not trust", value: { trusted: false, remember: true } }, { label: "Do not trust", value: { trusted: false, remember: true } },
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } }, { 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, 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: { async function resolveProjectTrusted(options: {
@@ -509,6 +627,9 @@ async function resolveProjectTrusted(options: {
trustOverride?: boolean; trustOverride?: boolean;
appMode: AppMode; appMode: AppMode;
settingsManagerForPrompt: SettingsManager; settingsManagerForPrompt: SettingsManager;
extensionsResult?: LoadExtensionsResult;
projectTrustContext?: ProjectTrustContext;
onExtensionError?: (message: string) => void;
}): Promise<boolean> { }): Promise<boolean> {
if (options.trustOverride !== undefined) { if (options.trustOverride !== undefined) {
return options.trustOverride; return options.trustOverride;
@@ -517,10 +638,37 @@ async function resolveProjectTrusted(options: {
return true; 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); const decision = options.trustStore.get(options.cwd);
if (decision !== null) { if (decision !== null) {
return decision; 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") { if (options.appMode !== "interactive") {
return false; return false;
} }
@@ -651,13 +799,7 @@ export async function main(args: string[], options?: MainOptions) {
const autoTrustOnReloadCwd = const autoTrustOnReloadCwd =
parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined; parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined;
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
const projectTrustedForSession = await resolveProjectTrusted({ const projectTrustByCwd = new Map<string, boolean>();
cwd: sessionCwd,
trustStore,
trustOverride: parsed.projectTrustOverride,
appMode: trustPromptMode,
settingsManagerForPrompt: startupSettingsManager,
});
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
@@ -669,11 +811,17 @@ export async function main(args: string[], options?: MainOptions) {
agentDir, agentDir,
sessionManager, sessionManager,
sessionStartEvent, sessionStartEvent,
projectTrustContext,
}) => { }) => {
const projectTrusted = const isInitialRuntime = sessionStartEvent === undefined;
cwd === sessionManager.getCwd() const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];
? projectTrustedForSession const cachedProjectTrust = projectTrustByCwd.get(cwd);
: (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true)); 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 runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
const services = await createAgentSessionServices({ const services = await createAgentSessionServices({
cwd, cwd,
@@ -681,6 +829,31 @@ export async function main(args: string[], options?: MainOptions) {
authStorage, authStorage,
settingsManager: runtimeSettingsManager, settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags, 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: { resourceLoaderOptions: {
additionalExtensionPaths: resolvedExtensionPaths, additionalExtensionPaths: resolvedExtensionPaths,
additionalSkillPaths: resolvedSkillPaths, additionalSkillPaths: resolvedSkillPaths,
@@ -698,6 +871,7 @@ export async function main(args: string[], options?: MainOptions) {
}); });
const { settingsManager, modelRegistry, resourceLoader } = services; const { settingsManager, modelRegistry, resourceLoader } = services;
const diagnostics: AgentSessionRuntimeDiagnostic[] = [ const diagnostics: AgentSessionRuntimeDiagnostic[] = [
...projectTrustDiagnostics,
...services.diagnostics, ...services.diagnostics,
...collectSettingsDiagnostics(settingsManager, "runtime creation"), ...collectSettingsDiagnostics(settingsManager, "runtime creation"),
...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({

View File

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

View File

@@ -187,6 +187,53 @@ Project skill`,
expect(extensionsResult.extensions[0].path).toBe(join(cwd, ".pi", "extensions", "shared.ts")); 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 () => { it("should keep both extensions loaded when command names collide", async () => {
const userExtDir = join(agentDir, "extensions"); const userExtDir = join(agentDir, "extensions");
const projectExtDir = join(cwd, ".pi", "extensions"); const projectExtDir = join(cwd, ".pi", "extensions");

View File

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