Merge branch 'main' into prompt-template-defaults
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
3
packages/coding-agent/src/core/experimental.ts
Normal file
3
packages/coding-agent/src/core/experimental.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function areExperimentalFeaturesEnabled(): boolean {
|
||||
return process.env.PI_EXPERIMENTAL === "1";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
95
packages/coding-agent/src/core/project-trust.ts
Normal file
95
packages/coding-agent/src/core/project-trust.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user