mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
feat(coding-agent): add extension project trust decisions
This commit is contained in:
parent
dce3e28516
commit
718215bd95
18 changed files with 631 additions and 86 deletions
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
59
packages/coding-agent/examples/extensions/project-trust.ts
Normal file
59
packages/coding-agent/examples/extensions/project-trust.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
|
|
@ -3,7 +3,12 @@ import { basename, join, resolve } from "node:path";
|
|||
import { resolvePath } from "../utils/paths.ts";
|
||||
import type { AgentSession } from "./agent-session.ts";
|
||||
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
|
||||
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
|
||||
import type {
|
||||
ProjectTrustContext,
|
||||
ReplacedSessionContext,
|
||||
SessionShutdownEvent,
|
||||
SessionStartEvent,
|
||||
} from "./extensions/index.ts";
|
||||
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
|
||||
import type { CreateAgentSessionResult } from "./sdk.ts";
|
||||
import { assertSessionCwdExists } from "./session-cwd.ts";
|
||||
|
|
@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: {
|
|||
agentDir: string;
|
||||
sessionManager: SessionManager;
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
projectTrustContext?: ProjectTrustContext;
|
||||
}) => Promise<CreateAgentSessionRuntimeResult>;
|
||||
|
||||
/**
|
||||
|
|
@ -186,7 +192,11 @@ export class AgentSessionRuntime {
|
|||
|
||||
async switchSession(
|
||||
sessionPath: string,
|
||||
options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
|
||||
options?: {
|
||||
cwdOverride?: string;
|
||||
withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
|
||||
projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;
|
||||
},
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
|
||||
if (beforeResult.cancelled) {
|
||||
|
|
@ -203,6 +213,7 @@ export class AgentSessionRuntime {
|
|||
agentDir: this.services.agentDir,
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
|
||||
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
|
||||
}),
|
||||
);
|
||||
await this.finishSessionReplacement(options?.withSession);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import { resolvePath } from "../utils/paths.ts";
|
|||
import { AuthStorage } from "./auth-storage.ts";
|
||||
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
|
||||
import { ModelRegistry } from "./model-registry.ts";
|
||||
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts";
|
||||
import {
|
||||
DefaultResourceLoader,
|
||||
type DefaultResourceLoaderOptions,
|
||||
type ResourceLoader,
|
||||
type ResourceLoaderReloadOptions,
|
||||
} from "./resource-loader.ts";
|
||||
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
|
||||
import type { SessionManager } from "./session-manager.ts";
|
||||
import { SettingsManager } from "./settings-manager.ts";
|
||||
|
|
@ -38,6 +43,7 @@ export interface CreateAgentSessionServicesOptions {
|
|||
modelRegistry?: ModelRegistry;
|
||||
extensionFlagValues?: Map<string, boolean | string>;
|
||||
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
|
||||
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -142,7 +148,7 @@ export async function createAgentSessionServices(
|
|||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
await resourceLoader.reload(options.resourceLoaderReloadOptions);
|
||||
|
||||
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
||||
const extensionsResult = resourceLoader.getExtensions();
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ export type {
|
|||
MessageUpdateEvent,
|
||||
ModelSelectEvent,
|
||||
ModelSelectSource,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventResult,
|
||||
ProjectTrustHandler,
|
||||
// Provider Registration
|
||||
ProviderConfig,
|
||||
ProviderModelConfig,
|
||||
|
|
|
|||
|
|
@ -410,15 +410,20 @@ export async function loadExtensionFromFactory(
|
|||
/**
|
||||
* Load extensions from paths.
|
||||
*/
|
||||
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
|
||||
export async function loadExtensions(
|
||||
paths: string[],
|
||||
cwd: string,
|
||||
eventBus?: EventBus,
|
||||
runtime?: ExtensionRuntime,
|
||||
): Promise<LoadExtensionsResult> {
|
||||
const extensions: Extension[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
const resolvedEventBus = eventBus ?? createEventBus();
|
||||
const runtime = createExtensionRuntime();
|
||||
const resolvedRuntime = runtime ?? createExtensionRuntime();
|
||||
|
||||
for (const extPath of paths) {
|
||||
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
|
||||
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime);
|
||||
|
||||
if (error) {
|
||||
errors.push({ path: extPath, error });
|
||||
|
|
@ -433,7 +438,7 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev
|
|||
return {
|
||||
extensions,
|
||||
errors,
|
||||
runtime,
|
||||
runtime: resolvedRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -495,9 +495,31 @@ export function defineTool<TParams extends TSchema, TDetails = unknown, TState =
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resource Events
|
||||
// Startup/Resource Events
|
||||
// ============================================================================
|
||||
|
||||
export interface ProjectTrustEvent {
|
||||
type: "project_trust";
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface ProjectTrustEventResult {
|
||||
trusted: boolean;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectTrustContext {
|
||||
cwd: string;
|
||||
mode: ExtensionMode;
|
||||
hasUI: boolean;
|
||||
ui: Pick<ExtensionUIContext, "select" | "confirm" | "input" | "notify">;
|
||||
}
|
||||
|
||||
export type ProjectTrustHandler = (
|
||||
event: ProjectTrustEvent,
|
||||
ctx: ProjectTrustContext,
|
||||
) => Promise<ProjectTrustEventResult> | ProjectTrustEventResult;
|
||||
|
||||
/** Fired after session_start to allow extensions to provide additional resource paths. */
|
||||
export interface ResourcesDiscoverEvent {
|
||||
type: "resources_discover";
|
||||
|
|
@ -957,6 +979,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
|
|||
|
||||
/** Union of all event types */
|
||||
export type ExtensionEvent =
|
||||
| ProjectTrustEvent
|
||||
| ResourcesDiscoverEvent
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
|
|
@ -1095,6 +1118,7 @@ export interface ExtensionAPI {
|
|||
// Event Subscription
|
||||
// =========================================================================
|
||||
|
||||
on(event: "project_trust", handler: ProjectTrustHandler): void;
|
||||
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
|
||||
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
||||
on(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
|
|||
import { createEventBus, type EventBus } from "./event-bus.ts";
|
||||
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
|
||||
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
|
||||
import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts";
|
||||
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
|
||||
import type { PromptTemplate } from "./prompt-templates.ts";
|
||||
import { loadPromptTemplates } from "./prompt-templates.ts";
|
||||
import { SettingsManager } from "./settings-manager.ts";
|
||||
|
|
@ -25,6 +25,10 @@ export interface ResourceExtensionPaths {
|
|||
themePaths?: Array<{ path: string; metadata: PathMetadata }>;
|
||||
}
|
||||
|
||||
export interface ResourceLoaderReloadOptions {
|
||||
resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ResourceLoader {
|
||||
getExtensions(): LoadExtensionsResult;
|
||||
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||
|
|
@ -34,7 +38,7 @@ export interface ResourceLoader {
|
|||
getSystemPrompt(): string | undefined;
|
||||
getAppendSystemPrompt(): string[];
|
||||
extendResources(paths: ResourceExtensionPaths): void;
|
||||
reload(): Promise<void>;
|
||||
reload(options?: ResourceLoaderReloadOptions): Promise<void>;
|
||||
}
|
||||
|
||||
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
|
||||
|
|
@ -321,7 +325,19 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
}
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
|
||||
let preTrustExtensions: LoadExtensionsResult | undefined;
|
||||
if (options?.resolveProjectTrust) {
|
||||
// Force untrusted project settings for the bootstrap pass. This keeps project-local
|
||||
// extensions/packages out while still loading user/global and temporary CLI extensions.
|
||||
this.settingsManager.setProjectTrusted(false);
|
||||
await this.settingsManager.reload();
|
||||
preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true });
|
||||
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
|
||||
this.settingsManager.setProjectTrusted(projectTrusted);
|
||||
}
|
||||
|
||||
// reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state.
|
||||
await this.settingsManager.reload();
|
||||
const resolvedPaths = await this.packageManager.resolve();
|
||||
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||
|
|
@ -334,9 +350,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
this.extensionThemeSourceInfos = new Map();
|
||||
|
||||
// Helper to extract enabled paths and store metadata
|
||||
const getEnabledResources = (
|
||||
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
|
||||
): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {
|
||||
const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => {
|
||||
for (const r of resources) {
|
||||
if (!metadataByPath.has(r.path)) {
|
||||
metadataByPath.set(r.path, r.metadata);
|
||||
|
|
@ -345,37 +359,14 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
return resources.filter((r) => r.enabled);
|
||||
};
|
||||
|
||||
const getEnabledPaths = (
|
||||
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
|
||||
): string[] => getEnabledResources(resources).map((r) => r.path);
|
||||
const getEnabledPaths = (resources: ResolvedResource[]): string[] =>
|
||||
getEnabledResources(resources).map((r) => r.path);
|
||||
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);
|
||||
const enabledSkillResources = getEnabledResources(resolvedPaths.skills);
|
||||
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);
|
||||
const enabledThemes = getEnabledPaths(resolvedPaths.themes);
|
||||
|
||||
const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => {
|
||||
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
|
||||
return resource.path;
|
||||
}
|
||||
try {
|
||||
const stats = statSync(resource.path);
|
||||
if (!stats.isDirectory()) {
|
||||
return resource.path;
|
||||
}
|
||||
} catch {
|
||||
return resource.path;
|
||||
}
|
||||
const skillFile = join(resource.path, "SKILL.md");
|
||||
if (existsSync(skillFile)) {
|
||||
if (!metadataByPath.has(skillFile)) {
|
||||
metadataByPath.set(skillFile, resource.metadata);
|
||||
}
|
||||
return skillFile;
|
||||
}
|
||||
return resource.path;
|
||||
};
|
||||
|
||||
const enabledSkills = enabledSkillResources.map(mapSkillPath);
|
||||
const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));
|
||||
|
||||
// Add CLI paths metadata
|
||||
for (const r of cliExtensionPaths.extensions) {
|
||||
|
|
@ -398,18 +389,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
? cliEnabledExtensions
|
||||
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
|
||||
|
||||
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
|
||||
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
||||
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
|
||||
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
||||
for (const conflict of conflicts) {
|
||||
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
||||
}
|
||||
|
||||
const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions);
|
||||
for (const p of this.additionalExtensionPaths) {
|
||||
if (isLocalPath(p)) {
|
||||
const resolved = this.resolveResourcePath(p);
|
||||
|
|
@ -497,6 +477,115 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
: baseAppend;
|
||||
}
|
||||
|
||||
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
|
||||
const resolvedPaths = await this.packageManager.resolve();
|
||||
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||
temporary: true,
|
||||
});
|
||||
const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const extensionPaths = this.noExtensions
|
||||
? cliEnabledExtensions
|
||||
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
|
||||
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||
if (!options.includeInlineFactories) {
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
private resolveExtensionLoadPath(path: string): string {
|
||||
return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
|
||||
}
|
||||
|
||||
private async loadFinalExtensionSet(
|
||||
extensionPaths: string[],
|
||||
preTrustExtensions: LoadExtensionsResult | undefined,
|
||||
): Promise<LoadExtensionsResult> {
|
||||
if (!preTrustExtensions) {
|
||||
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
const preloadedByPath = new Map(
|
||||
preTrustExtensions.extensions
|
||||
.filter((extension) => !extension.path.startsWith("<inline:"))
|
||||
.map((extension) => [extension.resolvedPath, extension]),
|
||||
);
|
||||
const failedPreloadPaths = new Set(
|
||||
preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)),
|
||||
);
|
||||
const remainingPaths = extensionPaths.filter((path) => {
|
||||
const resolvedPath = this.resolveExtensionLoadPath(path);
|
||||
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
|
||||
});
|
||||
const remainingExtensions = await loadExtensions(
|
||||
remainingPaths,
|
||||
this.cwd,
|
||||
this.eventBus,
|
||||
preTrustExtensions.runtime,
|
||||
);
|
||||
const loadedByPath = new Map(preloadedByPath);
|
||||
for (const extension of remainingExtensions.extensions) {
|
||||
loadedByPath.set(extension.resolvedPath, extension);
|
||||
}
|
||||
|
||||
const inlineExtensions = preTrustExtensions.extensions.filter((extension) =>
|
||||
extension.path.startsWith("<inline:"),
|
||||
);
|
||||
const orderedExtensions = extensionPaths
|
||||
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
|
||||
.filter((extension): extension is Extension => extension !== undefined);
|
||||
orderedExtensions.push(...inlineExtensions);
|
||||
|
||||
const extensionsResult: LoadExtensionsResult = {
|
||||
extensions: orderedExtensions,
|
||||
errors: [...preTrustExtensions.errors, ...remainingExtensions.errors],
|
||||
runtime: preTrustExtensions.runtime,
|
||||
};
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void {
|
||||
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
||||
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
|
||||
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
||||
for (const conflict of conflicts) {
|
||||
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
||||
}
|
||||
}
|
||||
|
||||
private mapSkillPath(resource: ResolvedResource, metadataByPath: Map<string, PathMetadata>): string {
|
||||
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
|
||||
return resource.path;
|
||||
}
|
||||
try {
|
||||
const stats = statSync(resource.path);
|
||||
if (!stats.isDirectory()) {
|
||||
return resource.path;
|
||||
}
|
||||
} catch {
|
||||
return resource.path;
|
||||
}
|
||||
const skillFile = join(resource.path, "SKILL.md");
|
||||
if (existsSync(skillFile)) {
|
||||
if (!metadataByPath.has(skillFile)) {
|
||||
metadataByPath.set(skillFile, resource.metadata);
|
||||
}
|
||||
return skillFile;
|
||||
}
|
||||
return resource.path;
|
||||
}
|
||||
|
||||
private normalizeExtensionPaths(
|
||||
entries: Array<{ path: string; metadata: PathMetadata }>,
|
||||
): Array<{ path: string; metadata: PathMetadata }> {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ export type {
|
|||
LsToolCallEvent,
|
||||
MessageRenderer,
|
||||
MessageRenderOptions,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventResult,
|
||||
ProjectTrustHandler,
|
||||
ProviderConfig,
|
||||
ProviderModelConfig,
|
||||
ReadToolCallEvent,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ import {
|
|||
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
|
||||
import { AuthStorage } from "./core/auth-storage.ts";
|
||||
import { exportFromFile } from "./core/export-html/index.ts";
|
||||
import type { ExtensionFactory } from "./core/extensions/types.ts";
|
||||
import { emitProjectTrustEvent } from "./core/extensions/runner.ts";
|
||||
import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts";
|
||||
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
|
||||
import { KeybindingsManager } from "./core/keybindings.ts";
|
||||
import type { ModelRegistry } from "./core/model-registry.ts";
|
||||
|
|
@ -43,6 +44,7 @@ import { printTimings, resetTimings, time } from "./core/timings.ts";
|
|||
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||
import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts";
|
||||
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
||||
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
|
||||
|
|
@ -437,24 +439,35 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
|
|||
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
|
||||
}
|
||||
|
||||
function createStartupTui(settingsManager: SettingsManager): TUI {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
return ui;
|
||||
}
|
||||
|
||||
async function clearStartupTui(ui: TUI): Promise<void> {
|
||||
ui.clear();
|
||||
ui.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
async function showStartupSelector<T>(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = (result: T | undefined) => {
|
||||
const finish = async (result: T | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
|
@ -462,8 +475,8 @@ async function showStartupSelector<T>(
|
|||
const selector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options.map((option) => option.label),
|
||||
(option) => finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => finish(undefined),
|
||||
(option) => void finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => void finish(undefined),
|
||||
{ tui: ui },
|
||||
);
|
||||
ui.addChild(selector);
|
||||
|
|
@ -472,6 +485,41 @@ async function showStartupSelector<T>(
|
|||
});
|
||||
}
|
||||
|
||||
async function showStartupInput(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
placeholder?: string,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
input.dispose();
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const input = new ExtensionInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => void finish(value),
|
||||
() => void finish(undefined),
|
||||
{
|
||||
tui: ui,
|
||||
},
|
||||
);
|
||||
ui.addChild(input);
|
||||
ui.setFocus(input);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForMissingSessionCwd(
|
||||
issue: SessionCwdIssue,
|
||||
settingsManager: SettingsManager,
|
||||
|
|
@ -487,20 +535,90 @@ interface ProjectTrustPromptResult {
|
|||
remember: boolean;
|
||||
}
|
||||
|
||||
const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [
|
||||
{ label: "Trust", value: { trusted: true, remember: true } },
|
||||
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
|
||||
{ label: "Do not trust", value: { trusted: false, remember: true } },
|
||||
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
|
||||
];
|
||||
|
||||
function formatProjectTrustPrompt(cwd: string): string {
|
||||
return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`;
|
||||
}
|
||||
|
||||
async function promptForProjectTrust(
|
||||
cwd: string,
|
||||
settingsManager: SettingsManager,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
return showStartupSelector(
|
||||
settingsManager,
|
||||
`Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`,
|
||||
[
|
||||
{ label: "Trust", value: { trusted: true, remember: true } },
|
||||
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
|
||||
{ label: "Do not trust", value: { trusted: false, remember: true } },
|
||||
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
|
||||
],
|
||||
return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS);
|
||||
}
|
||||
|
||||
async function promptForProjectTrustWithContext(
|
||||
cwd: string,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
const selected = await ctx.ui.select(
|
||||
formatProjectTrustPrompt(cwd),
|
||||
PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label),
|
||||
);
|
||||
return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value;
|
||||
}
|
||||
|
||||
function createProjectTrustContext(options: {
|
||||
cwd: string;
|
||||
mode: AppMode;
|
||||
settingsManager: SettingsManager;
|
||||
hasUI: boolean;
|
||||
}): ProjectTrustContext {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
mode: options.mode === "interactive" ? "tui" : options.mode,
|
||||
hasUI: options.hasUI,
|
||||
ui: {
|
||||
select: async (title, selectOptions) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupSelector(
|
||||
options.settingsManager,
|
||||
title,
|
||||
selectOptions.map((option) => ({ label: option, value: option })),
|
||||
);
|
||||
},
|
||||
confirm: async (title, message) => {
|
||||
if (!options.hasUI) {
|
||||
return false;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false },
|
||||
])) ?? false
|
||||
);
|
||||
},
|
||||
input: async (title, placeholder) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupInput(options.settingsManager, title, placeholder);
|
||||
},
|
||||
notify: (message, type = "info") => {
|
||||
if (options.mode !== "interactive") {
|
||||
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
|
||||
console.error(color(message));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProjectTrusted(options: {
|
||||
|
|
@ -509,6 +627,9 @@ async function resolveProjectTrusted(options: {
|
|||
trustOverride?: boolean;
|
||||
appMode: AppMode;
|
||||
settingsManagerForPrompt: SettingsManager;
|
||||
extensionsResult?: LoadExtensionsResult;
|
||||
projectTrustContext?: ProjectTrustContext;
|
||||
onExtensionError?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (options.trustOverride !== undefined) {
|
||||
return options.trustOverride;
|
||||
|
|
@ -517,10 +638,37 @@ async function resolveProjectTrusted(options: {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (options.extensionsResult && options.projectTrustContext) {
|
||||
const { result, errors } = await emitProjectTrustEvent(
|
||||
options.extensionsResult,
|
||||
{ type: "project_trust", cwd: options.cwd },
|
||||
options.projectTrustContext,
|
||||
);
|
||||
for (const error of errors) {
|
||||
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
||||
}
|
||||
if (result) {
|
||||
if (result.remember === true) {
|
||||
options.trustStore.set(options.cwd, result.trusted);
|
||||
}
|
||||
return result.trusted;
|
||||
}
|
||||
}
|
||||
|
||||
const decision = options.trustStore.get(options.cwd);
|
||||
if (decision !== null) {
|
||||
return decision;
|
||||
}
|
||||
if (options.projectTrustContext?.hasUI) {
|
||||
const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext);
|
||||
if (selected !== undefined) {
|
||||
if (selected.remember) {
|
||||
options.trustStore.set(options.cwd, selected.trusted);
|
||||
}
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (options.appMode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -651,13 +799,7 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
const autoTrustOnReloadCwd =
|
||||
parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined;
|
||||
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
|
||||
const projectTrustedForSession = await resolveProjectTrusted({
|
||||
cwd: sessionCwd,
|
||||
trustStore,
|
||||
trustOverride: parsed.projectTrustOverride,
|
||||
appMode: trustPromptMode,
|
||||
settingsManagerForPrompt: startupSettingsManager,
|
||||
});
|
||||
const projectTrustByCwd = new Map<string, boolean>();
|
||||
|
||||
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
|
||||
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
|
||||
|
|
@ -669,11 +811,17 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
agentDir,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
projectTrustContext,
|
||||
}) => {
|
||||
const projectTrusted =
|
||||
cwd === sessionManager.getCwd()
|
||||
? projectTrustedForSession
|
||||
: (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true));
|
||||
const isInitialRuntime = sessionStartEvent === undefined;
|
||||
const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
||||
const cachedProjectTrust = projectTrustByCwd.get(cwd);
|
||||
const hasTrustInputs = hasProjectTrustInputs(cwd);
|
||||
const shouldResolveProjectTrust =
|
||||
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs;
|
||||
const projectTrusted = shouldResolveProjectTrust
|
||||
? false
|
||||
: (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true));
|
||||
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
|
|
@ -681,6 +829,31 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
authStorage,
|
||||
settingsManager: runtimeSettingsManager,
|
||||
extensionFlagValues: parsed.unknownFlags,
|
||||
resourceLoaderReloadOptions: shouldResolveProjectTrust
|
||||
? {
|
||||
resolveProjectTrust: async ({ extensionsResult }) => {
|
||||
const trusted = await resolveProjectTrusted({
|
||||
cwd,
|
||||
trustStore,
|
||||
trustOverride: parsed.projectTrustOverride,
|
||||
appMode: isInitialRuntime ? trustPromptMode : "print",
|
||||
settingsManagerForPrompt: startupSettingsManager,
|
||||
extensionsResult,
|
||||
projectTrustContext:
|
||||
projectTrustContext ??
|
||||
createProjectTrustContext({
|
||||
cwd,
|
||||
mode: isInitialRuntime ? trustPromptMode : appMode,
|
||||
settingsManager: startupSettingsManager,
|
||||
hasUI: isInitialRuntime && trustPromptMode === "interactive",
|
||||
}),
|
||||
onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
|
||||
});
|
||||
projectTrustByCwd.set(cwd, trusted);
|
||||
return trusted;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
resourceLoaderOptions: {
|
||||
additionalExtensionPaths: resolvedExtensionPaths,
|
||||
additionalSkillPaths: resolvedSkillPaths,
|
||||
|
|
@ -698,6 +871,7 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
});
|
||||
const { settingsManager, modelRegistry, resourceLoader } = services;
|
||||
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
|
||||
...projectTrustDiagnostics,
|
||||
...services.diagnostics,
|
||||
...collectSettingsDiagnostics(settingsManager, "runtime creation"),
|
||||
...resourceLoader.getExtensions().errors.map(({ path, error }) => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -187,6 +187,53 @@ Project skill`,
|
|||
expect(extensionsResult.extensions[0].path).toBe(join(cwd, ".pi", "extensions", "shared.ts"));
|
||||
});
|
||||
|
||||
it("should load user extensions before trust and reuse them after trust resolves", async () => {
|
||||
const userExtDir = join(agentDir, "extensions");
|
||||
const projectExtDir = join(cwd, ".pi", "extensions");
|
||||
mkdirSync(userExtDir, { recursive: true });
|
||||
mkdirSync(projectExtDir, { recursive: true });
|
||||
const loadCountKey = `__piTrustPreloadCount_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const globalState = globalThis as typeof globalThis & Record<string, number | undefined>;
|
||||
|
||||
writeFileSync(
|
||||
join(userExtDir, "user.ts"),
|
||||
`globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1;
|
||||
export default function(pi) {
|
||||
pi.on("project_trust", () => ({ trusted: true }));
|
||||
pi.registerCommand("user-trust", {
|
||||
description: "user trust",
|
||||
handler: async () => {},
|
||||
});
|
||||
}`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectExtDir, "project.ts"),
|
||||
`export default function(pi) {
|
||||
pi.registerCommand("project-trusted", {
|
||||
description: "project trusted",
|
||||
handler: async () => {},
|
||||
});
|
||||
}`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload({
|
||||
resolveProjectTrust: async ({ extensionsResult }) => {
|
||||
expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([
|
||||
join(userExtDir, "user.ts"),
|
||||
]);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const extensionsResult = loader.getExtensions();
|
||||
expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([
|
||||
join(cwd, ".pi", "extensions", "project.ts"),
|
||||
join(userExtDir, "user.ts"),
|
||||
]);
|
||||
expect(globalState[loadCountKey]).toBe(1);
|
||||
});
|
||||
|
||||
it("should keep both extensions loaded when command names collide", async () => {
|
||||
const userExtDir = join(agentDir, "extensions");
|
||||
const projectExtDir = join(cwd, ".pi", "extensions");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
44
packages/tui/test/tui-shrink.test.ts
Normal file
44
packages/tui/test/tui-shrink.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue