fix(coding-agent): remove process-cwd tool singletons and use tool-name allowlists

- switch SDK/CLI tool selection to name-based allowlists
- apply allowlists across built-in, extension, and SDK tools
- remove ambient process.cwd defaults from core tooling and resource helpers
- update tests, examples, and TUI callers for explicit cwd plumbing
- add regression coverage for extension tool filtering

closes #3452
closes #2835
This commit is contained in:
Mario Zechner
2026-04-20 21:57:31 +02:00
Unverified
parent 27c1544839
commit 5a4e22ea44
42 changed files with 254 additions and 201 deletions
+8 -3
View File
@@ -741,11 +741,16 @@ Customize the inline working indicator shown while pi is streaming a response.
```typescript
// Static indicator
ctx.ui.setWorkingIndicator({ frames: ["●"] });
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
// Custom animated indicator
ctx.ui.setWorkingIndicator({
frames: ["·", "•", "●", "•"],
frames: [
ctx.ui.theme.fg("dim", "·"),
ctx.ui.theme.fg("muted", "•"),
ctx.ui.theme.fg("accent", "●"),
ctx.ui.theme.fg("muted", "•"),
],
intervalMs: 120,
});
@@ -756,7 +761,7 @@ ctx.ui.setWorkingIndicator({ frames: [] });
ctx.ui.setWorkingIndicator();
```
This only affects the normal streaming working indicator. Compaction and retry loaders keep their built-in styling.
This only affects the normal streaming working indicator. Compaction and retry loaders keep their built-in styling. Custom frames are rendered verbatim, so extensions must add their own colors when needed.
**Examples:** [working-indicator.ts](../examples/extensions/working-indicator.ts)
@@ -20,31 +20,49 @@ import type { ExtensionAPI, ExtensionContext, WorkingIndicatorOptions } from "@m
type WorkingIndicatorMode = "dot" | "none" | "pulse" | "spinner" | "default";
const SPINNER_INDICATOR: WorkingIndicatorOptions = {
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
intervalMs: 80,
};
const DOT_INDICATOR: WorkingIndicatorOptions = {
frames: ["●"],
};
const PULSE_INDICATOR: WorkingIndicatorOptions = {
frames: ["·", "•", "●", "•"],
intervalMs: 120,
};
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const PASTEL_RAINBOW = [
"\x1b[38;2;255;179;186m",
"\x1b[38;2;255;223;186m",
"\x1b[38;2;255;255;186m",
"\x1b[38;2;186;255;201m",
"\x1b[38;2;186;225;255m",
"\x1b[38;2;218;186;255m",
];
const RESET_FG = "\x1b[39m";
const HIDDEN_INDICATOR: WorkingIndicatorOptions = {
frames: [],
};
function colorize(text: string, color: string): string {
return `${color}${text}${RESET_FG}`;
}
function getIndicator(mode: WorkingIndicatorMode): WorkingIndicatorOptions | undefined {
switch (mode) {
case "dot":
return DOT_INDICATOR;
return {
frames: [colorize("●", PASTEL_RAINBOW[0])],
};
case "none":
return HIDDEN_INDICATOR;
case "pulse":
return PULSE_INDICATOR;
return {
frames: [
colorize("·", PASTEL_RAINBOW[0]),
colorize("•", PASTEL_RAINBOW[2]),
colorize("●", PASTEL_RAINBOW[4]),
colorize("•", PASTEL_RAINBOW[5]),
],
intervalMs: 120,
};
case "spinner":
return SPINNER_INDICATOR;
return {
frames: SPINNER_FRAMES.map((frame, index) =>
colorize(frame, PASTEL_RAINBOW[index % PASTEL_RAINBOW.length]!),
),
intervalMs: 80,
};
case "default":
return undefined;
}
@@ -66,7 +84,7 @@ function describeMode(mode: WorkingIndicatorMode): string {
}
export default function (pi: ExtensionAPI) {
let mode: WorkingIndicatorMode = "dot";
let mode: WorkingIndicatorMode = "spinner";
const applyIndicator = (ctx: ExtensionContext) => {
ctx.ui.setWorkingIndicator(getIndicator(mode));
@@ -4,10 +4,15 @@
* Shows how to replace or modify the default system prompt.
*/
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@mariozechner/pi-coding-agent";
const cwd = process.cwd();
const agentDir = getAgentDir();
// Option 1: Replace prompt entirely
const loader1 = new DefaultResourceLoader({
cwd,
agentDir,
systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate.
Always end responses with "Arrr!"`,
// Needed to avoid DefaultResourceLoader appending APPEND_SYSTEM.md from ~/.pi/agent or <cwd>/.pi.
@@ -32,6 +37,8 @@ console.log("\n");
// Option 2: Append instructions to the default prompt
const loader2 = new DefaultResourceLoader({
cwd,
agentDir,
appendSystemPromptOverride: (base) => [
...base,
"## Additional Instructions\n- Always be concise\n- Use bullet points when listing things",
@@ -9,6 +9,7 @@ import {
createAgentSession,
createSyntheticSourceInfo,
DefaultResourceLoader,
getAgentDir,
SessionManager,
type Skill,
} from "@mariozechner/pi-coding-agent";
@@ -24,6 +25,8 @@ const customSkill: Skill = {
};
const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
skillsOverride: (current) => {
const filteredSkills = current.skills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
return {
@@ -13,12 +13,14 @@
* export default function (pi: ExtensionAPI) { ... }
*/
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@mariozechner/pi-coding-agent";
// Extensions are discovered automatically from standard locations.
// You can also add paths via settings.json or DefaultResourceLoader options.
const resourceLoader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
extensionFactories: [
(pi) => {
@@ -4,10 +4,12 @@
* Context files provide project-specific instructions loaded into the system prompt.
*/
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@mariozechner/pi-coding-agent";
// Disable context files entirely by returning an empty list in agentsFilesOverride.
const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
agentsFilesOverride: (current) => ({
agentsFiles: [
...current.agentsFiles,
@@ -8,6 +8,7 @@ import {
createAgentSession,
createSyntheticSourceInfo,
DefaultResourceLoader,
getAgentDir,
type PromptTemplate,
SessionManager,
} from "@mariozechner/pi-coding-agent";
@@ -26,6 +27,8 @@ const deployTemplate: PromptTemplate = {
};
const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptsOverride: (current) => ({
prompts: [...current.prompts, deployTemplate],
diagnostics: current.diagnostics,
@@ -6,12 +6,14 @@
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
const cwd = process.cwd();
// Load current settings (merged global + project)
const settingsManagerFromDisk = SettingsManager.create();
const settingsManagerFromDisk = SettingsManager.create(cwd);
console.log("Current settings:", JSON.stringify(settingsManagerFromDisk.getGlobalSettings(), null, 2));
// Override specific settings
const settingsManager = SettingsManager.create();
const settingsManager = SettingsManager.create(cwd);
settingsManager.applyOverrides({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 },
@@ -133,9 +133,6 @@ export class AgentSessionRuntime {
}
private apply(result: CreateAgentSessionRuntimeResult): void {
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
this._session = result.session;
this._services = result.services;
this._diagnostics = result.diagnostics;
@@ -346,9 +343,6 @@ export async function createAgentSessionRuntime(
): Promise<AgentSessionRuntime> {
assertSessionCwdExists(options.sessionManager, options.cwd);
const result = await createRuntime(options);
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
return new AgentSessionRuntime(
result.session,
result.services,
@@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../utils/shell.js";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.js";
import type { BashOperations } from "./tools/bash.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
// ============================================================================
@@ -43,23 +43,6 @@ export interface BashResult {
// Implementation
// ============================================================================
/**
* Execute a bash command with optional streaming and cancellation support.
*
* Uses the same local BashOperations backend as createBashTool() so interactive
* user bash and tool-invoked bash share the same process spawning behavior.
* Sanitization, newline normalization, temp-file capture, and truncation still
* happen in executeBashWithOperations(), so reusing the local backend does not
* change output processing behavior.
*
* @param command - The bash command to execute
* @param options - Optional streaming callback and abort signal
* @returns Promise resolving to execution result
*/
export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
return executeBashWithOperations(command, process.cwd(), createLocalBashOperations(), options);
}
/**
* Execute a bash command using custom BashOperations.
* Used for remote execution (SSH, containers, etc.).
@@ -104,7 +104,7 @@ export type TerminalInputHandler = (data: string) => { consume?: boolean; data?:
/** Working indicator configuration for the interactive streaming loader. */
export interface WorkingIndicatorOptions {
/** Animation frames. Use an empty array to hide the indicator entirely. */
/** Animation frames. Use an empty array to hide the indicator entirely. Custom frames are rendered verbatim. */
frames?: string[];
/** Frame interval in milliseconds for animated indicators. */
intervalMs?: number;
@@ -142,6 +142,7 @@ export interface ExtensionUIContext {
* - Omit the argument to restore the default animated spinner.
* - Use `frames: ["●"]` for a static indicator.
* - Use `frames: []` to hide the indicator entirely.
* - Custom frames are rendered as provided, so extensions must add their own colors.
*/
setWorkingIndicator(options?: WorkingIndicatorOptions): void;
@@ -101,7 +101,7 @@ export class FooterDataProvider {
private refreshPending = false;
private disposed = false;
constructor(cwd: string = process.cwd()) {
constructor(cwd: string) {
this.cwd = cwd;
this.gitPaths = findGitPaths(cwd);
this.setupGitWatcher();
+1 -1
View File
@@ -25,7 +25,7 @@ export {
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";
export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor.js";
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
// Extensions system
@@ -1,7 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
import { CONFIG_DIR_NAME } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
@@ -175,14 +175,14 @@ function loadTemplatesFromDir(dir: string, getSourceInfo: (filePath: string) =>
}
export interface LoadPromptTemplatesOptions {
/** Working directory for project-local templates. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global templates. Default: from getPromptsDir() */
agentDir?: string;
/** Explicit prompt template paths (files or directories) */
promptPaths?: string[];
/** Include default prompt directories. Default: true */
includeDefaults?: boolean;
/** Working directory for project-local templates. */
cwd: string;
/** Agent config directory for global templates. */
agentDir: string;
/** Explicit prompt template paths (files or directories). */
promptPaths: string[];
/** Include default prompt directories. */
includeDefaults: boolean;
}
function normalizePath(input: string): string {
@@ -204,11 +204,11 @@ function resolvePromptPath(p: string, cwd: string): string {
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
* 3. Explicit prompt paths
*/
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
const promptPaths = options.promptPaths ?? [];
const includeDefaults = options.includeDefaults ?? true;
export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const promptPaths = options.promptPaths;
const includeDefaults = options.includeDefaults;
const templates: PromptTemplate[] = [];
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, sep } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { CONFIG_DIR_NAME } from "../config.js";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
@@ -73,11 +73,12 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
return null;
}
export function loadProjectContextFiles(
options: { cwd?: string; agentDir?: string } = {},
): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getAgentDir();
export function loadProjectContextFiles(options: {
cwd: string;
agentDir: string;
}): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
@@ -113,8 +114,8 @@ export function loadProjectContextFiles(
}
export interface DefaultResourceLoaderOptions {
cwd?: string;
agentDir?: string;
cwd: string;
agentDir: string;
settingsManager?: SettingsManager;
eventBus?: EventBus;
additionalExtensionPaths?: string[];
@@ -204,8 +205,8 @@ export class DefaultResourceLoader implements ResourceLoader {
private lastThemePaths: string[];
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd ?? process.cwd();
this.agentDir = options.agentDir ?? getAgentDir();
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({
@@ -144,7 +144,7 @@ export class FileSettingsStorage implements SettingsStorage {
private globalSettingsPath: string;
private projectSettingsPath: string;
constructor(cwd: string = process.cwd(), agentDir: string = getAgentDir()) {
constructor(cwd: string, agentDir: string) {
this.globalSettingsPath = join(agentDir, "settings.json");
this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
}
@@ -256,7 +256,7 @@ export class SettingsManager {
}
/** Create a SettingsManager that loads from files */
static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager {
static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager {
const storage = new FileSettingsStorage(cwd, agentDir);
return SettingsManager.fromStorage(storage);
}
+9 -9
View File
@@ -374,14 +374,14 @@ function escapeXml(str: string): string {
}
export interface LoadSkillsOptions {
/** Working directory for project-local skills. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global skills. Default: ~/.pi/agent */
agentDir?: string;
/** Working directory for project-local skills. */
cwd: string;
/** Agent config directory for global skills. */
agentDir: string;
/** Explicit skill paths (files or directories) */
skillPaths?: string[];
/** Include default skills directories. Default: true */
includeDefaults?: boolean;
skillPaths: string[];
/** Include default skills directories. */
includeDefaults: boolean;
}
function normalizePath(input: string): string {
@@ -401,8 +401,8 @@ function resolveSkillPath(p: string, cwd: string): string {
* Load skills from all configured locations.
* Returns skills and any validation diagnostics.
*/
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const { cwd = process.cwd(), agentDir, skillPaths = [], includeDefaults = true } = options;
export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const { cwd, agentDir, skillPaths, includeDefaults } = options;
// Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir();
@@ -16,8 +16,8 @@ export interface BuildSystemPromptOptions {
promptGuidelines?: string[];
/** Text to append to system prompt. */
appendSystemPrompt?: string;
/** Working directory. Default: process.cwd() */
cwd?: string;
/** Working directory. */
cwd: string;
/** Pre-loaded context files. */
contextFiles?: Array<{ path: string; content: string }>;
/** Pre-loaded skills. */
@@ -25,7 +25,7 @@ export interface BuildSystemPromptOptions {
}
/** Build the system prompt with tools, guidelines, and context */
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
const {
customPrompt,
selectedTools,
@@ -36,7 +36,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
contextFiles: providedContextFiles,
skills: providedSkills,
} = options;
const resolvedCwd = cwd ?? process.cwd();
const resolvedCwd = cwd;
const promptCwd = resolvedCwd.replace(/\\/g, "/");
const now = new Date();
@@ -444,7 +444,3 @@ export function createBashToolDefinition(
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
return wrapToolDefinition(createBashToolDefinition(cwd, options));
}
/** Default bash tool using process.cwd() for backwards compatibility. */
export const bashToolDefinition = createBashToolDefinition(process.cwd());
export const bashTool = createBashTool(process.cwd());
@@ -485,7 +485,3 @@ export function createEditToolDefinition(
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
return wrapToolDefinition(createEditToolDefinition(cwd, options));
}
/** Default edit tool using process.cwd() for backwards compatibility. */
export const editToolDefinition = createEditToolDefinition(process.cwd());
export const editTool = createEditTool(process.cwd());
@@ -368,7 +368,3 @@ export function createFindToolDefinition(
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
return wrapToolDefinition(createFindToolDefinition(cwd, options));
}
/** Default find tool using process.cwd() for backwards compatibility. */
export const findToolDefinition = createFindToolDefinition(process.cwd());
export const findTool = createFindTool(process.cwd());
@@ -382,7 +382,3 @@ export function createGrepToolDefinition(
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
return wrapToolDefinition(createGrepToolDefinition(cwd, options));
}
/** Default grep tool using process.cwd() for backwards compatibility. */
export const grepToolDefinition = createGrepToolDefinition(process.cwd());
export const grepTool = createGrepTool(process.cwd());
@@ -227,7 +227,3 @@ export function createLsToolDefinition(
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
return wrapToolDefinition(createLsToolDefinition(cwd, options));
}
/** Default ls tool using process.cwd() for backwards compatibility. */
export const lsToolDefinition = createLsToolDefinition(process.cwd());
export const lsTool = createLsTool(process.cwd());
@@ -271,7 +271,3 @@ export function createReadToolDefinition(
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
return wrapToolDefinition(createReadToolDefinition(cwd, options));
}
/** Default read tool using process.cwd() for backwards compatibility. */
export const readToolDefinition = createReadToolDefinition(process.cwd());
export const readTool = createReadTool(process.cwd());
@@ -279,7 +279,3 @@ export function createWriteToolDefinition(
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
return wrapToolDefinition(createWriteToolDefinition(cwd, options));
}
/** Default write tool using process.cwd() for backwards compatibility. */
export const writeToolDefinition = createWriteToolDefinition(process.cwd());
export const writeTool = createWriteTool(process.cwd());
-17
View File
@@ -183,8 +183,6 @@ export {
createReadTool,
createWriteTool,
type PromptTemplate,
// Pre-built tools (use process.cwd())
readOnlyTools,
} from "./core/sdk.js";
export {
type BranchSummaryEntry,
@@ -235,9 +233,6 @@ export {
type BashToolDetails,
type BashToolInput,
type BashToolOptions,
bashTool,
bashToolDefinition,
codingTools,
createBashToolDefinition,
createEditToolDefinition,
createFindToolDefinition,
@@ -252,33 +247,23 @@ export {
type EditToolDetails,
type EditToolInput,
type EditToolOptions,
editTool,
editToolDefinition,
type FindOperations,
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
findTool,
findToolDefinition,
formatSize,
type GrepOperations,
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
grepTool,
grepToolDefinition,
type LsOperations,
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
lsTool,
lsToolDefinition,
type ReadOperations,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
readTool,
readToolDefinition,
type ToolsOptions,
type TruncationOptions,
type TruncationResult,
@@ -289,8 +274,6 @@ export {
type WriteToolInput,
type WriteToolOptions,
withFileMutationQueue,
writeTool,
writeToolDefinition,
} from "./core/tools/index.js";
// Main entry point
export { type MainOptions, main } from "./main.js";
+1 -1
View File
@@ -301,7 +301,7 @@ export async function showDeprecationWarnings(warnings: string[]): Promise<void>
*
* @returns Object with migration results and deprecation warnings
*/
export function runMigrations(cwd: string = process.cwd()): {
export function runMigrations(cwd: string): {
migratedAuthProviders: string[];
deprecationWarnings: string[];
} {
@@ -1,6 +1,6 @@
import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.js";
import { allToolDefinitions } from "../../../core/tools/index.js";
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.js";
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
import { convertToPng } from "../../../utils/image-convert.js";
import { theme } from "../theme/theme.js";
@@ -45,14 +45,14 @@ export class ToolExecutionComponent extends Container {
options: ToolExecutionOptions = {},
toolDefinition: ToolDefinition<any, any> | undefined,
ui: TUI,
cwd: string = process.cwd(),
cwd: string,
) {
super();
this.toolName = toolName;
this.toolCallId = toolCallId;
this.args = args;
this.toolDefinition = toolDefinition;
this.builtInToolDefinition = allToolDefinitions[toolName as keyof typeof allToolDefinitions];
this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName];
this.showImages = options.showImages ?? true;
this.ui = ui;
this.cwd = cwd;
+1 -1
View File
@@ -53,7 +53,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig;
}
const settings = SettingsManager.create();
const settings = SettingsManager.create(process.cwd());
const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path
@@ -18,7 +18,7 @@ import { AuthStorage } from "../src/core/auth-storage.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
import { createCodingTools } from "../src/index.js";
import { API_KEY, createTestResourceLoader } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
@@ -52,7 +52,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
initialState: {
model,
systemPrompt: "You are a helpful assistant. Be concise.",
tools: codingTools,
tools: createCodingTools(process.cwd()),
},
});
@@ -3,8 +3,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { executeBash } from "../src/core/bash-executor.js";
import { createBashTool } from "../src/core/tools/bash.js";
import { executeBashWithOperations } from "../src/core/bash-executor.js";
import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
function toBashSingleQuotedArg(value: string): string {
return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`;
@@ -87,9 +87,15 @@ describe.skipIf(process.platform !== "win32")("Windows child-process close handl
const controller = new AbortController();
try {
const result = await withTimeout(executeBash(command, { signal: controller.signal }), 3000, () => {
controller.abort();
});
const result = await withTimeout(
executeBashWithOperations(command, process.cwd(), createLocalBashOperations(), {
signal: controller.signal,
}),
3000,
() => {
controller.abort();
},
);
expect(result.output).toContain("child-exiting");
expect(result.exitCode).toBe(0);
@@ -21,7 +21,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
import { codingTools } from "../src/core/tools/index.js";
import { createCodingTools } from "../src/index.js";
import { createTestResourceLoader } from "./utilities.js";
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
@@ -92,7 +92,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
initialState: {
model,
systemPrompt: "You are a helpful assistant. Be concise.",
tools: codingTools,
tools: createCodingTools(process.cwd()),
},
});
@@ -112,7 +112,7 @@ describe("FooterDataProvider reftable branch detection", () => {
mkdirSync(nestedDir, { recursive: true });
process.chdir(nestedDir);
const provider = new FooterDataProvider();
const provider = new FooterDataProvider(nestedDir);
try {
expect(provider.getGitBranch()).toBe("main");
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
@@ -125,7 +125,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const repoDir = createPlainReftableRepo(tempDir);
process.chdir(repoDir);
const provider = new FooterDataProvider();
const provider = new FooterDataProvider(repoDir);
try {
expect(provider.getGitBranch()).toBe("main");
expect(vi.mocked(spawnSync)).toHaveBeenCalledWith(
@@ -146,7 +146,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir);
const provider = new FooterDataProvider();
const provider = new FooterDataProvider(worktreeDir);
try {
expect(provider.getGitBranch()).toBe("main");
} finally {
@@ -159,7 +159,7 @@ describe("FooterDataProvider reftable branch detection", () => {
process.chdir(repoDir);
resolvedBranch = "";
const provider = new FooterDataProvider();
const provider = new FooterDataProvider(repoDir);
try {
expect(provider.getGitBranch()).toBe("detached");
} finally {
@@ -171,7 +171,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir);
const provider = new FooterDataProvider();
const provider = new FooterDataProvider(worktreeDir);
try {
expect(provider.getGitBranch()).toBe("main");
vi.mocked(spawnSync).mockClear();
@@ -194,7 +194,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir);
const provider = new FooterDataProvider();
const provider = new FooterDataProvider(worktreeDir);
try {
expect(provider.getGitBranch()).toBe("main");
vi.mocked(execFile).mockClear();
@@ -215,7 +215,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir);
const provider = new FooterDataProvider();
const provider = new FooterDataProvider(worktreeDir);
try {
expect(provider.getGitBranch()).toBe("main");
resolvedBranch = "foo";
@@ -12,6 +12,7 @@ import { mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterAll, describe, expect, test } from "vitest";
import { getAgentDir } from "../src/config.js";
import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
// ============================================================================
@@ -406,6 +407,8 @@ You are given one or more GitHub PR URLs: $@`,
);
const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir],
includeDefaults: false,
});
@@ -427,6 +430,8 @@ Wrap it. Additional instructions: $ARGUMENTS`,
);
const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir],
includeDefaults: false,
});
@@ -447,6 +452,8 @@ Audit changelog entries for all commits since the last release.`,
);
const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir],
includeDefaults: false,
});
@@ -467,6 +474,8 @@ Do something`,
);
const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir],
includeDefaults: false,
});
@@ -487,6 +496,8 @@ Analyze GitHub issue(s): $ARGUMENTS`,
);
const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir],
includeDefaults: false,
});
@@ -354,6 +354,7 @@ describe("skills", () => {
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: [join(fixturesDir, "valid-skill")],
includeDefaults: true,
});
expect(skills).toHaveLength(1);
expect(skills[0].sourceInfo.scope).toBe("temporary");
@@ -365,6 +366,7 @@ describe("skills", () => {
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: ["/non/existent/path"],
includeDefaults: true,
});
expect(skills).toHaveLength(0);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not exist"))).toBe(true);
@@ -376,11 +378,13 @@ describe("skills", () => {
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: ["~/.pi/agent/skills"],
includeDefaults: true,
});
const { skills: withoutTilde } = loadSkills({
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: [homeSkillsDir],
includeDefaults: true,
});
expect(withTilde.length).toBe(withoutTilde.length);
});
@@ -8,6 +8,7 @@ describe("buildSystemPrompt", () => {
selectedTools: [],
contextFiles: [],
skills: [],
cwd: process.cwd(),
});
expect(prompt).toContain("Available tools:\n(none)");
@@ -18,6 +19,7 @@ describe("buildSystemPrompt", () => {
selectedTools: [],
contextFiles: [],
skills: [],
cwd: process.cwd(),
});
expect(prompt).toContain("Show file paths clearly");
@@ -35,6 +37,7 @@ describe("buildSystemPrompt", () => {
},
contextFiles: [],
skills: [],
cwd: process.cwd(),
});
expect(prompt).toContain("- read:");
@@ -53,6 +56,7 @@ describe("buildSystemPrompt", () => {
},
contextFiles: [],
skills: [],
cwd: process.cwd(),
});
expect(prompt).toContain("- dynamic_tool: Run dynamic test behavior");
@@ -63,6 +67,7 @@ describe("buildSystemPrompt", () => {
selectedTools: ["read", "dynamic_tool"],
contextFiles: [],
skills: [],
cwd: process.cwd(),
});
expect(prompt).not.toContain("dynamic_tool");
@@ -76,6 +81,7 @@ describe("buildSystemPrompt", () => {
promptGuidelines: ["Use dynamic_tool for project summaries."],
contextFiles: [],
skills: [],
cwd: process.cwd(),
});
expect(prompt).toContain("- Use dynamic_tool for project summaries.");
@@ -87,6 +93,7 @@ describe("buildSystemPrompt", () => {
promptGuidelines: ["Use dynamic_tool for summaries.", " Use dynamic_tool for summaries. ", " "],
contextFiles: [],
skills: [],
cwd: process.cwd(),
});
expect(prompt.match(/- Use dynamic_tool for summaries\./g)).toHaveLength(1);
@@ -40,7 +40,15 @@ describe("ToolExecutionComponent parity", () => {
renderResult: () => new Text("custom result", 0, 0),
};
const component = new ToolExecutionComponent("custom_tool", "tool-1", {}, {}, toolDefinition, createFakeTui());
const component = new ToolExecutionComponent(
"custom_tool",
"tool-1",
{},
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
component.updateResult(
@@ -69,6 +77,7 @@ describe("ToolExecutionComponent parity", () => {
{},
overrideDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false });
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -85,6 +94,7 @@ describe("ToolExecutionComponent parity", () => {
{},
undefined,
createFakeTui(),
process.cwd(),
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read");
@@ -119,6 +129,7 @@ describe("ToolExecutionComponent parity", () => {
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -138,6 +149,7 @@ describe("ToolExecutionComponent parity", () => {
{},
overrideDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -158,6 +170,7 @@ describe("ToolExecutionComponent parity", () => {
{},
overrideDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -179,6 +192,7 @@ describe("ToolExecutionComponent parity", () => {
renderResult: () => new Text("override result", 0, 0),
},
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -201,6 +215,7 @@ describe("ToolExecutionComponent parity", () => {
renderResult: () => new Text("wrapped override result", 0, 0),
},
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -221,7 +236,15 @@ describe("ToolExecutionComponent parity", () => {
},
};
const component = new ToolExecutionComponent("custom_tool", "tool-5", {}, {}, toolDefinition, createFakeTui());
const component = new ToolExecutionComponent(
"custom_tool",
"tool-5",
{},
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom call shared-token");
@@ -243,6 +266,7 @@ describe("ToolExecutionComponent parity", () => {
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -261,6 +285,7 @@ describe("ToolExecutionComponent parity", () => {
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
@@ -276,6 +301,7 @@ describe("ToolExecutionComponent parity", () => {
{},
createWriteToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("one");
@@ -291,6 +317,7 @@ describe("ToolExecutionComponent parity", () => {
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
component.updateResult(
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },
+24 -10
View File
@@ -2,16 +2,26 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeBash } from "../src/core/bash-executor.js";
import { bashTool, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { editTool } from "../src/core/tools/edit.js";
import { findTool } from "../src/core/tools/find.js";
import { grepTool } from "../src/core/tools/grep.js";
import { lsTool } from "../src/core/tools/ls.js";
import { readTool } from "../src/core/tools/read.js";
import { writeTool } from "../src/core/tools/write.js";
import { executeBashWithOperations } from "../src/core/bash-executor.js";
import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import {
createEditTool,
createFindTool,
createGrepTool,
createLsTool,
createReadTool,
createWriteTool,
} from "../src/index.js";
import * as shellModule from "../src/utils/shell.js";
const readTool = createReadTool(process.cwd());
const writeTool = createWriteTool(process.cwd());
const editTool = createEditTool(process.cwd());
const bashTool = createBashTool(process.cwd());
const grepTool = createGrepTool(process.cwd());
const findTool = createFindTool(process.cwd());
const lsTool = createLsTool(process.cwd());
// Helper to extract text from content blocks
function getTextOutput(result: any): string {
return (
@@ -438,7 +448,11 @@ describe("Coding Agent Tools", () => {
});
it("should preserve executeBash sanitization when using local bash operations", async () => {
const result = await executeBash("printf '\\033[31mred\\033[0m\\r\\n'");
const result = await executeBashWithOperations(
"printf '\\033[31mred\\033[0m\\r\\n'",
process.cwd(),
createLocalBashOperations(),
);
expect(result.exitCode).toBe(0);
expect(result.output).toBe("red\n");
@@ -468,7 +482,7 @@ describe("Coding Agent Tools", () => {
});
it("executeBash should persist full output when truncation happens by line count only", async () => {
const result = await executeBash("seq 3000");
const result = await executeBashWithOperations("seq 3000", process.cwd(), createLocalBashOperations());
const fullOutputPath = result.fullOutputPath;
expect(result.truncated).toBe(true);
+2 -2
View File
@@ -17,7 +17,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import type { ResourceLoader } from "../src/core/resource-loader.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
import { createCodingTools } from "../src/index.js";
/**
* API key for authenticated tests. Tests using this should be wrapped in
@@ -242,7 +242,7 @@ export function createTestSession(options: TestSessionOptions = {}): TestSession
initialState: {
model,
systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.",
tools: codingTools,
tools: createCodingTools(process.cwd()),
},
});
+1 -5
View File
@@ -265,11 +265,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
private basePath: string;
private fdPath: string | null;
constructor(
commands: (SlashCommand | AutocompleteItem)[] = [],
basePath: string = process.cwd(),
fdPath: string | null = null,
) {
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) {
this.commands = commands;
this.basePath = basePath;
this.fdPath = fdPath;
+5 -2
View File
@@ -20,6 +20,7 @@ export class Loader extends Text {
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
private renderIndicatorVerbatim = false;
constructor(
ui: TUI,
@@ -55,7 +56,8 @@ export class Loader extends Text {
}
setIndicator(indicator?: LoaderIndicatorOptions): void {
this.frames = indicator?.frames ? [...indicator.frames] : [...DEFAULT_FRAMES];
this.renderIndicatorVerbatim = indicator !== undefined;
this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES];
this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;
this.currentFrame = 0;
this.start();
@@ -74,7 +76,8 @@ export class Loader extends Text {
private updateDisplay(): void {
const frame = this.frames[this.currentFrame] ?? "";
const indicator = frame.length > 0 ? `${this.spinnerColorFn(frame)} ` : "";
const renderedFrame = this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);
const indicator = frame.length > 0 ? `${renderedFrame} ` : "";
this.setText(`${indicator}${this.messageColorFn(this.message)}`);
if (this.ui) {
this.ui.requestRender();
+34 -25
View File
@@ -2476,14 +2476,17 @@ describe("Editor component", () => {
it("awaits async slash command argument completions", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([
{
name: "load-skills",
description: "Load skills",
getArgumentCompletions: async (prefix) =>
prefix.startsWith("s") ? [{ value: "skill-a", label: "skill-a" }] : null,
},
]);
const provider = new CombinedAutocompleteProvider(
[
{
name: "load-skills",
description: "Load skills",
getArgumentCompletions: async (prefix) =>
prefix.startsWith("s") ? [{ value: "skill-a", label: "skill-a" }] : null,
},
],
process.cwd(),
);
editor.setAutocompleteProvider(provider);
editor.setText("/load-skills ");
@@ -2498,15 +2501,18 @@ describe("Editor component", () => {
it("ignores invalid slash command argument completion results", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([
{
name: "load-skills",
description: "Load skills",
getArgumentCompletions: (() => "not-an-array") as unknown as (
argumentPrefix: string,
) => Promise<{ value: string; label: string }[] | null>,
},
]);
const provider = new CombinedAutocompleteProvider(
[
{
name: "load-skills",
description: "Load skills",
getArgumentCompletions: (() => "not-an-array") as unknown as (
argumentPrefix: string,
) => Promise<{ value: string; label: string }[] | null>,
},
],
process.cwd(),
);
editor.setAutocompleteProvider(provider);
editor.setText("/load-skills ");
@@ -2518,14 +2524,17 @@ describe("Editor component", () => {
it("does not show argument completions when command has no argument completer", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([
{ name: "help", description: "Show help" },
{
name: "model",
description: "Switch model",
getArgumentCompletions: () => [{ value: "claude-opus", label: "claude-opus" }],
},
]);
const provider = new CombinedAutocompleteProvider(
[
{ name: "help", description: "Show help" },
{
name: "model",
description: "Switch model",
getArgumentCompletions: () => [{ value: "claude-opus", label: "claude-opus" }],
},
],
process.cwd(),
);
editor.setAutocompleteProvider(provider);
editor.handleInput("/");