diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index afd84aa9..5acf40df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ad6fb655..202b6f3e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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`. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 0d63ceb6..53152ca0 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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` | diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 97103347..d4f4f2cc 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -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 | diff --git a/packages/coding-agent/examples/extensions/project-trust.ts b/packages/coding-agent/examples/extensions/project-trust.ts new file mode 100644 index 00000000..66020a54 --- /dev/null +++ b/packages/coding-agent/examples/extensions/project-trust.ts @@ -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 => { + 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"); + }); +} diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index baab11b0..be855ea4 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -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; /** @@ -186,7 +192,11 @@ export class AgentSessionRuntime { async switchSession( sessionPath: string, - options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise }, + options?: { + cwdOverride?: string; + withSession?: (ctx: ReplacedSessionContext) => Promise; + 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); diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index d66adc13..3a6035cb 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -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; resourceLoaderOptions?: Omit; + 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(); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index a25fa37b..a20887ed 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -98,6 +98,10 @@ export type { MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventResult, + ProjectTrustHandler, // Provider Registration ProviderConfig, ProviderModelConfig, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 6a7d8abb..081d2d11 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -410,15 +410,20 @@ export async function loadExtensionFromFactory( /** * Load extensions from paths. */ -export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { +export async function loadExtensions( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { 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, }; } diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 59412dcb..db4ba0be 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index a78a0657..9fd3ffdf 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -495,9 +495,31 @@ export function defineTool; +} + +export type ProjectTrustHandler = ( + event: ProjectTrustEvent, + ctx: ProjectTrustContext, +) => Promise | 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): void; on(event: "session_start", handler: ExtensionHandler): void; on( diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 1221366a..394679bb 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -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; +} + 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; + reload(options?: ResourceLoaderReloadOptions): Promise; } function resolvePromptInput(input: string | undefined, description: string): string | undefined { @@ -321,7 +325,19 @@ export class DefaultResourceLoader implements ResourceLoader { } } - async reload(): Promise { + async reload(options?: ResourceLoaderReloadOptions): Promise { + 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 { + 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 { + 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(" [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(" 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 { + 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 }> { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7ddbe5da..677c030b 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -98,6 +98,10 @@ export type { LsToolCallEvent, MessageRenderer, MessageRenderOptions, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventResult, + ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 99846761..4387af6d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 { + ui.clear(); + ui.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 25)); +} + async function showStartupSelector( settingsManager: SettingsManager, title: string, options: Array<{ label: string; value: T }>, ): Promise { - 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( 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( }); } +async function showStartupInput( + settingsManager: SettingsManager, + title: string, + placeholder?: string, +): Promise { + 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 { - 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 { + 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 { 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(); 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 }) => ({ diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c0e22282..f90cffab 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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; diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 88dd5895..779f6064 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -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; + + 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"); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f746ee68..9f6dda4b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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); diff --git a/packages/tui/test/tui-shrink.test.ts b/packages/tui/test/tui-shrink.test.ts new file mode 100644 index 00000000..13fa5c4b --- /dev/null +++ b/packages/tui/test/tui-shrink.test.ts @@ -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(); + }); +});