Merge branch 'main' into prompt-template-defaults

This commit is contained in:
Mario Zechner
2026-06-09 14:23:00 +02:00
committed by GitHub
65 changed files with 2021 additions and 551 deletions

View File

@@ -232,7 +232,9 @@ export class AgentSessionRuntime {
const previousSessionFile = this.session.sessionFile;
const sessionDir = this.session.sessionManager.getSessionDir();
const sessionManager = SessionManager.create(this.cwd, sessionDir);
const sessionManager = this.session.sessionManager.isPersisted()
? SessionManager.create(this.cwd, sessionDir)
: SessionManager.inMemory(this.cwd);
if (options?.parentSession) {
sessionManager.newSession({ parentSession: options.parentSession });
}

View File

@@ -1606,6 +1606,11 @@ export class AgentSession {
// Queue Mode Management
// =========================================================================
private syncQueueModesFromSettings(): void {
this.agent.steeringMode = this.settingsManager.getSteeringMode();
this.agent.followUpMode = this.settingsManager.getFollowUpMode();
}
/**
* Set steering message mode.
* Saves to settings.
@@ -2239,6 +2244,7 @@ export class AgentSession {
{
getModel: () => this.model,
isIdle: () => !this.isStreaming,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
getSignal: () => this.agent.signal,
abort: () => {
if (this._extensionAbortHandler) {
@@ -2430,6 +2436,7 @@ export class AgentSession {
const previousFlagValues = this._extensionRunner.getFlagValues();
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
await this.settingsManager.reload();
this.syncQueueModesFromSettings();
resetApiProviders();
await this._resourceLoader.reload();
this._buildRuntime({

View File

@@ -0,0 +1,3 @@
export function areExperimentalFeaturesEnabled(): boolean {
return process.env.PI_EXPERIMENTAL === "1";
}

View File

@@ -270,6 +270,7 @@ export class ExtensionRunner {
private errorListeners: Set<ExtensionErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
private isIdleFn: () => boolean = () => true;
private isProjectTrustedFn: () => boolean = () => true;
private getSignalFn: () => AbortSignal | undefined = () => undefined;
private waitForIdleFn: () => Promise<void> = async () => {};
private abortFn: () => void = () => {};
@@ -330,6 +331,7 @@ export class ExtensionRunner {
// Context actions (required)
this.getModel = contextActions.getModel;
this.isIdleFn = contextActions.isIdle;
this.isProjectTrustedFn = contextActions.isProjectTrusted;
this.getSignalFn = contextActions.getSignal;
this.abortFn = contextActions.abort;
this.hasPendingMessagesFn = contextActions.hasPendingMessages;
@@ -648,6 +650,10 @@ export class ExtensionRunner {
runner.assertActive();
return runner.isIdleFn();
},
isProjectTrusted: () => {
runner.assertActive();
return runner.isProjectTrustedFn();
},
get signal() {
runner.assertActive();
return runner.getSignalFn();

View File

@@ -314,6 +314,8 @@ export interface ExtensionContext {
model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Whether project-local trust is active for this context. */
isProjectTrusted(): boolean;
/** The current abort signal, or undefined when the agent is not streaming. */
signal: AbortSignal | undefined;
/** Abort the current agent operation */
@@ -1528,6 +1530,7 @@ export interface ExtensionActions {
export interface ExtensionContextActions {
getModel: () => Model<any> | undefined;
isIdle: () => boolean;
isProjectTrusted: () => boolean;
getSignal: () => AbortSignal | undefined;
abort: () => void;
hasPendingMessages: () => boolean;

View File

@@ -28,6 +28,7 @@ export {
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts";
export type { CompactionResult } from "./compaction/index.ts";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts";
export { areExperimentalFeaturesEnabled } from "./experimental.ts";
// Extensions system
export {
type AgentEndEvent,

View File

@@ -0,0 +1,95 @@
import { emitProjectTrustEvent } from "./extensions/runner.ts";
import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts";
import type { DefaultProjectTrust } from "./settings-manager.ts";
import {
getProjectTrustOptions,
hasProjectTrustInputs,
type ProjectTrustOption,
type ProjectTrustStore,
} from "./trust-manager.ts";
export type AppMode = "interactive" | "print" | "json" | "rpc";
export interface ResolveProjectTrustedOptions {
cwd: string;
trustStore: ProjectTrustStore;
trustOverride?: boolean;
defaultProjectTrust?: DefaultProjectTrust;
extensionsResult?: LoadExtensionsResult;
projectTrustContext: ProjectTrustContext;
onExtensionError?: (message: string) => void;
}
function formatProjectTrustPrompt(cwd: string): string {
return `Trust project folder?\n${cwd}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.`;
}
async function selectProjectTrustOption(
cwd: string,
ctx: ProjectTrustContext,
): Promise<ProjectTrustOption | undefined> {
const options = getProjectTrustOptions(cwd, { includeSessionOnly: true });
const selected = await ctx.ui.select(
formatProjectTrustPrompt(cwd),
options.map((option) => option.label),
);
return options.find((option) => option.label === selected);
}
function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void {
if (result.updates.length > 0) {
trustStore.setMany(result.updates);
}
}
export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise<boolean> {
if (options.trustOverride !== undefined) {
return options.trustOverride;
}
if (!hasProjectTrustInputs(options.cwd)) {
return true;
}
if (options.extensionsResult) {
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) {
const trusted = result.trusted === "yes";
if (result.remember === true) {
options.trustStore.set(options.cwd, trusted);
}
return trusted;
}
}
const decision = options.trustStore.get(options.cwd);
if (decision !== null) {
return decision;
}
switch (options.defaultProjectTrust ?? "ask") {
case "always":
return true;
case "never":
return false;
case "ask":
break;
}
if (!options.projectTrustContext.hasUI) {
return false;
}
const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext);
if (selected !== undefined) {
saveProjectTrustPromptResult(options.trustStore, selected);
return selected.trusted;
}
return false;
}

View File

@@ -79,7 +79,6 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
export function loadProjectContextFiles(options: {
cwd: string;
agentDir: string;
projectTrusted?: boolean;
}): Array<{ path: string; content: string }> {
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
@@ -93,29 +92,27 @@ export function loadProjectContextFiles(options: {
seenPaths.add(globalContext.path);
}
if (options.projectTrusted !== false) {
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
let currentDir = resolvedCwd;
const root = resolve("/");
let currentDir = resolvedCwd;
const root = resolve("/");
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
contextFiles.push(...ancestorContextFiles);
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
}
contextFiles.push(...ancestorContextFiles);
return contextFiles;
}
@@ -325,14 +322,18 @@ export class DefaultResourceLoader implements ResourceLoader {
}
}
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> {
// 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();
return this.loadCurrentExtensionSet({ includeInlineFactories: true });
}
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 });
preTrustExtensions = await this.loadProjectTrustExtensions();
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
this.settingsManager.setProjectTrusted(projectTrusted);
}
@@ -454,7 +455,6 @@ export class DefaultResourceLoader implements ResourceLoader {
: loadProjectContextFiles({
cwd: this.cwd,
agentDir: this.agentDir,
projectTrusted: this.settingsManager.isProjectTrusted(),
}),
};
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;

View File

@@ -57,6 +57,8 @@ export interface WarningSettings {
anthropicExtraUsage?: boolean; // default: true
}
export type DefaultProjectTrust = "ask" | "always" | "never";
export type TransportSetting = Transport;
/**
@@ -89,6 +91,7 @@ export interface Settings {
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
quietStartup?: boolean;
defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
@@ -853,6 +856,17 @@ export class SettingsManager {
this.save();
}
getDefaultProjectTrust(): DefaultProjectTrust {
const value = this.globalSettings.defaultProjectTrust;
return value === "always" || value === "never" ? value : "ask";
}
setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void {
this.globalSettings.defaultProjectTrust = defaultProjectTrust;
this.markModified("defaultProjectTrust");
this.save();
}
getShellCommandPrefix(): string | undefined {
return this.settings.shellCommandPrefix;
}

View File

@@ -6,14 +6,87 @@ import { canonicalizePath, resolvePath } from "../utils/paths.ts";
export type ProjectTrustDecision = boolean | null;
type TrustFile = Record<string, boolean | null | undefined>;
export interface ProjectTrustStoreEntry {
path: string;
decision: boolean;
}
const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
export interface ProjectTrustUpdate {
path: string;
decision: ProjectTrustDecision;
}
export interface ProjectTrustOption {
label: string;
trusted: boolean;
updates: ProjectTrustUpdate[];
savedPath?: string;
}
type TrustFile = Record<string, boolean | null | undefined>;
function normalizeCwd(cwd: string): string {
return canonicalizePath(resolvePath(cwd));
}
function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null {
let currentDir = normalizeCwd(cwd);
while (true) {
const value = data[currentDir];
if (value === true || value === false) {
return { path: currentDir, decision: value };
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
export function getProjectTrustPath(cwd: string): string {
return normalizeCwd(cwd);
}
export function getProjectTrustParentPath(cwd: string): string | undefined {
const trustPath = getProjectTrustPath(cwd);
const parentDir = dirname(trustPath);
return parentDir === trustPath ? undefined : parentDir;
}
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
const trustPath = getProjectTrustPath(cwd);
const trustOptions: ProjectTrustOption[] = [
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
];
const parentPath = getProjectTrustParentPath(cwd);
if (parentPath !== undefined) {
trustOptions.push({
label: `Trust parent folder (${parentPath})`,
trusted: true,
updates: [
{ path: parentPath, decision: true },
{ path: trustPath, decision: null },
],
savedPath: parentPath,
});
}
if (options?.includeSessionOnly) {
trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] });
}
trustOptions.push({
label: "Do not trust",
trusted: false,
updates: [{ path: trustPath, decision: false }],
savedPath: trustPath,
});
if (options?.includeSessionOnly) {
trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] });
}
return trustOptions;
}
function readTrustFile(path: string): TrustFile {
if (!existsSync(path)) {
return {};
@@ -105,11 +178,6 @@ export function hasProjectTrustInputs(cwd: string): boolean {
}
while (true) {
for (const filename of CONTEXT_FILE_NAMES) {
if (existsSync(join(currentDir, filename))) {
return true;
}
}
if (existsSync(join(currentDir, ".agents", "skills"))) {
return true;
}
@@ -130,21 +198,30 @@ export class ProjectTrustStore {
}
get(cwd: string): ProjectTrustDecision {
return this.getEntry(cwd)?.decision ?? null;
}
getEntry(cwd: string): ProjectTrustStoreEntry | null {
return withTrustFileLock(this.trustPath, () => {
const data = readTrustFile(this.trustPath);
const value = data[normalizeCwd(cwd)];
return value === true || value === false ? value : null;
return findNearestTrustEntry(data, cwd);
});
}
set(cwd: string, decision: ProjectTrustDecision): void {
this.setMany([{ path: cwd, decision }]);
}
setMany(decisions: ProjectTrustUpdate[]): void {
withTrustFileLock(this.trustPath, () => {
const data = readTrustFile(this.trustPath);
const key = normalizeCwd(cwd);
if (decision === null) {
delete data[key];
} else {
data[key] = decision;
for (const { path, decision } of decisions) {
const key = normalizeCwd(path);
if (decision === null) {
delete data[key];
} else {
data[key] = decision;
}
}
writeTrustFile(this.trustPath, data);
});