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
parent 27c1544839
commit 5a4e22ea44
42 changed files with 254 additions and 201 deletions

View File

@@ -741,11 +741,16 @@ Customize the inline working indicator shown while pi is streaming a response.
```typescript ```typescript
// Static indicator // Static indicator
ctx.ui.setWorkingIndicator({ frames: ["●"] }); ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
// Custom animated indicator // Custom animated indicator
ctx.ui.setWorkingIndicator({ 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, intervalMs: 120,
}); });
@@ -756,7 +761,7 @@ ctx.ui.setWorkingIndicator({ frames: [] });
ctx.ui.setWorkingIndicator(); 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) **Examples:** [working-indicator.ts](../examples/extensions/working-indicator.ts)

View File

@@ -20,31 +20,49 @@ import type { ExtensionAPI, ExtensionContext, WorkingIndicatorOptions } from "@m
type WorkingIndicatorMode = "dot" | "none" | "pulse" | "spinner" | "default"; type WorkingIndicatorMode = "dot" | "none" | "pulse" | "spinner" | "default";
const SPINNER_INDICATOR: WorkingIndicatorOptions = { const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], const PASTEL_RAINBOW = [
intervalMs: 80, "\x1b[38;2;255;179;186m",
}; "\x1b[38;2;255;223;186m",
const DOT_INDICATOR: WorkingIndicatorOptions = { "\x1b[38;2;255;255;186m",
frames: ["●"], "\x1b[38;2;186;255;201m",
}; "\x1b[38;2;186;225;255m",
const PULSE_INDICATOR: WorkingIndicatorOptions = { "\x1b[38;2;218;186;255m",
frames: ["·", "•", "●", "•"], ];
intervalMs: 120, const RESET_FG = "\x1b[39m";
};
const HIDDEN_INDICATOR: WorkingIndicatorOptions = { const HIDDEN_INDICATOR: WorkingIndicatorOptions = {
frames: [], frames: [],
}; };
function colorize(text: string, color: string): string {
return `${color}${text}${RESET_FG}`;
}
function getIndicator(mode: WorkingIndicatorMode): WorkingIndicatorOptions | undefined { function getIndicator(mode: WorkingIndicatorMode): WorkingIndicatorOptions | undefined {
switch (mode) { switch (mode) {
case "dot": case "dot":
return DOT_INDICATOR; return {
frames: [colorize("●", PASTEL_RAINBOW[0])],
};
case "none": case "none":
return HIDDEN_INDICATOR; return HIDDEN_INDICATOR;
case "pulse": 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": case "spinner":
return SPINNER_INDICATOR; return {
frames: SPINNER_FRAMES.map((frame, index) =>
colorize(frame, PASTEL_RAINBOW[index % PASTEL_RAINBOW.length]!),
),
intervalMs: 80,
};
case "default": case "default":
return undefined; return undefined;
} }
@@ -66,7 +84,7 @@ function describeMode(mode: WorkingIndicatorMode): string {
} }
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
let mode: WorkingIndicatorMode = "dot"; let mode: WorkingIndicatorMode = "spinner";
const applyIndicator = (ctx: ExtensionContext) => { const applyIndicator = (ctx: ExtensionContext) => {
ctx.ui.setWorkingIndicator(getIndicator(mode)); ctx.ui.setWorkingIndicator(getIndicator(mode));

View File

@@ -4,10 +4,15 @@
* Shows how to replace or modify the default system prompt. * 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 // Option 1: Replace prompt entirely
const loader1 = new DefaultResourceLoader({ const loader1 = new DefaultResourceLoader({
cwd,
agentDir,
systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate. systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate.
Always end responses with "Arrr!"`, Always end responses with "Arrr!"`,
// Needed to avoid DefaultResourceLoader appending APPEND_SYSTEM.md from ~/.pi/agent or <cwd>/.pi. // 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 // Option 2: Append instructions to the default prompt
const loader2 = new DefaultResourceLoader({ const loader2 = new DefaultResourceLoader({
cwd,
agentDir,
appendSystemPromptOverride: (base) => [ appendSystemPromptOverride: (base) => [
...base, ...base,
"## Additional Instructions\n- Always be concise\n- Use bullet points when listing things", "## Additional Instructions\n- Always be concise\n- Use bullet points when listing things",

View File

@@ -9,6 +9,7 @@ import {
createAgentSession, createAgentSession,
createSyntheticSourceInfo, createSyntheticSourceInfo,
DefaultResourceLoader, DefaultResourceLoader,
getAgentDir,
SessionManager, SessionManager,
type Skill, type Skill,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
@@ -24,6 +25,8 @@ const customSkill: Skill = {
}; };
const loader = new DefaultResourceLoader({ const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
skillsOverride: (current) => { skillsOverride: (current) => {
const filteredSkills = current.skills.filter((s) => s.name.includes("browser") || s.name.includes("search")); const filteredSkills = current.skills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
return { return {

View File

@@ -13,12 +13,14 @@
* export default function (pi: ExtensionAPI) { ... } * 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. // Extensions are discovered automatically from standard locations.
// You can also add paths via settings.json or DefaultResourceLoader options. // You can also add paths via settings.json or DefaultResourceLoader options.
const resourceLoader = new DefaultResourceLoader({ const resourceLoader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"], additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
extensionFactories: [ extensionFactories: [
(pi) => { (pi) => {

View File

@@ -4,10 +4,12 @@
* Context files provide project-specific instructions loaded into the system prompt. * 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. // Disable context files entirely by returning an empty list in agentsFilesOverride.
const loader = new DefaultResourceLoader({ const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
agentsFilesOverride: (current) => ({ agentsFilesOverride: (current) => ({
agentsFiles: [ agentsFiles: [
...current.agentsFiles, ...current.agentsFiles,

View File

@@ -8,6 +8,7 @@ import {
createAgentSession, createAgentSession,
createSyntheticSourceInfo, createSyntheticSourceInfo,
DefaultResourceLoader, DefaultResourceLoader,
getAgentDir,
type PromptTemplate, type PromptTemplate,
SessionManager, SessionManager,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
@@ -26,6 +27,8 @@ const deployTemplate: PromptTemplate = {
}; };
const loader = new DefaultResourceLoader({ const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptsOverride: (current) => ({ promptsOverride: (current) => ({
prompts: [...current.prompts, deployTemplate], prompts: [...current.prompts, deployTemplate],
diagnostics: current.diagnostics, diagnostics: current.diagnostics,

View File

@@ -6,12 +6,14 @@
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
const cwd = process.cwd();
// Load current settings (merged global + project) // 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)); console.log("Current settings:", JSON.stringify(settingsManagerFromDisk.getGlobalSettings(), null, 2));
// Override specific settings // Override specific settings
const settingsManager = SettingsManager.create(); const settingsManager = SettingsManager.create(cwd);
settingsManager.applyOverrides({ settingsManager.applyOverrides({
compaction: { enabled: false }, compaction: { enabled: false },
retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 }, retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 },

View File

@@ -133,9 +133,6 @@ export class AgentSessionRuntime {
} }
private apply(result: CreateAgentSessionRuntimeResult): void { private apply(result: CreateAgentSessionRuntimeResult): void {
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
this._session = result.session; this._session = result.session;
this._services = result.services; this._services = result.services;
this._diagnostics = result.diagnostics; this._diagnostics = result.diagnostics;
@@ -346,9 +343,6 @@ export async function createAgentSessionRuntime(
): Promise<AgentSessionRuntime> { ): Promise<AgentSessionRuntime> {
assertSessionCwdExists(options.sessionManager, options.cwd); assertSessionCwdExists(options.sessionManager, options.cwd);
const result = await createRuntime(options); const result = await createRuntime(options);
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
return new AgentSessionRuntime( return new AgentSessionRuntime(
result.session, result.session,
result.services, result.services,

View File

@@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import stripAnsi from "strip-ansi"; import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../utils/shell.js"; 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"; import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
// ============================================================================ // ============================================================================
@@ -43,23 +43,6 @@ export interface BashResult {
// Implementation // 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. * Execute a bash command using custom BashOperations.
* Used for remote execution (SSH, containers, etc.). * Used for remote execution (SSH, containers, etc.).

View File

@@ -104,7 +104,7 @@ export type TerminalInputHandler = (data: string) => { consume?: boolean; data?:
/** Working indicator configuration for the interactive streaming loader. */ /** Working indicator configuration for the interactive streaming loader. */
export interface WorkingIndicatorOptions { 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[]; frames?: string[];
/** Frame interval in milliseconds for animated indicators. */ /** Frame interval in milliseconds for animated indicators. */
intervalMs?: number; intervalMs?: number;
@@ -142,6 +142,7 @@ export interface ExtensionUIContext {
* - Omit the argument to restore the default animated spinner. * - Omit the argument to restore the default animated spinner.
* - Use `frames: ["●"]` for a static indicator. * - Use `frames: ["●"]` for a static indicator.
* - Use `frames: []` to hide the indicator entirely. * - Use `frames: []` to hide the indicator entirely.
* - Custom frames are rendered as provided, so extensions must add their own colors.
*/ */
setWorkingIndicator(options?: WorkingIndicatorOptions): void; setWorkingIndicator(options?: WorkingIndicatorOptions): void;

View File

@@ -101,7 +101,7 @@ export class FooterDataProvider {
private refreshPending = false; private refreshPending = false;
private disposed = false; private disposed = false;
constructor(cwd: string = process.cwd()) { constructor(cwd: string) {
this.cwd = cwd; this.cwd = cwd;
this.gitPaths = findGitPaths(cwd); this.gitPaths = findGitPaths(cwd);
this.setupGitWatcher(); this.setupGitWatcher();

View File

@@ -25,7 +25,7 @@ export {
createAgentSessionFromServices, createAgentSessionFromServices,
createAgentSessionServices, createAgentSessionServices,
} from "./agent-session-services.js"; } 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 type { CompactionResult } from "./compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js"; export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
// Extensions system // Extensions system

View File

@@ -1,7 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import { basename, dirname, isAbsolute, join, resolve, sep } from "path"; 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 { parseFrontmatter } from "../utils/frontmatter.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
@@ -175,14 +175,14 @@ function loadTemplatesFromDir(dir: string, getSourceInfo: (filePath: string) =>
} }
export interface LoadPromptTemplatesOptions { export interface LoadPromptTemplatesOptions {
/** Working directory for project-local templates. Default: process.cwd() */ /** Working directory for project-local templates. */
cwd?: string; cwd: string;
/** Agent config directory for global templates. Default: from getPromptsDir() */ /** Agent config directory for global templates. */
agentDir?: string; agentDir: string;
/** Explicit prompt template paths (files or directories) */ /** Explicit prompt template paths (files or directories). */
promptPaths?: string[]; promptPaths: string[];
/** Include default prompt directories. Default: true */ /** Include default prompt directories. */
includeDefaults?: boolean; includeDefaults: boolean;
} }
function normalizePath(input: string): string { function normalizePath(input: string): string {
@@ -204,11 +204,11 @@ function resolvePromptPath(p: string, cwd: string): string {
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/ * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
* 3. Explicit prompt paths * 3. Explicit prompt paths
*/ */
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] { export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] {
const resolvedCwd = options.cwd ?? process.cwd(); const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir ?? getPromptsDir(); const resolvedAgentDir = options.agentDir;
const promptPaths = options.promptPaths ?? []; const promptPaths = options.promptPaths;
const includeDefaults = options.includeDefaults ?? true; const includeDefaults = options.includeDefaults;
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];

View File

@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join, resolve, sep } from "node:path"; import { join, resolve, sep } from "node:path";
import chalk from "chalk"; 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 { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "./diagnostics.js"; import type { ResourceDiagnostic } from "./diagnostics.js";
@@ -73,11 +73,12 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
return null; return null;
} }
export function loadProjectContextFiles( export function loadProjectContextFiles(options: {
options: { cwd?: string; agentDir?: string } = {}, cwd: string;
): Array<{ path: string; content: string }> { agentDir: string;
const resolvedCwd = options.cwd ?? process.cwd(); }): Array<{ path: string; content: string }> {
const resolvedAgentDir = options.agentDir ?? getAgentDir(); const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const contextFiles: Array<{ path: string; content: string }> = []; const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>(); const seenPaths = new Set<string>();
@@ -113,8 +114,8 @@ export function loadProjectContextFiles(
} }
export interface DefaultResourceLoaderOptions { export interface DefaultResourceLoaderOptions {
cwd?: string; cwd: string;
agentDir?: string; agentDir: string;
settingsManager?: SettingsManager; settingsManager?: SettingsManager;
eventBus?: EventBus; eventBus?: EventBus;
additionalExtensionPaths?: string[]; additionalExtensionPaths?: string[];
@@ -204,8 +205,8 @@ export class DefaultResourceLoader implements ResourceLoader {
private lastThemePaths: string[]; private lastThemePaths: string[];
constructor(options: DefaultResourceLoaderOptions) { constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd ?? process.cwd(); this.cwd = options.cwd;
this.agentDir = options.agentDir ?? getAgentDir(); this.agentDir = options.agentDir;
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus(); this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({ this.packageManager = new DefaultPackageManager({

View File

@@ -144,7 +144,7 @@ export class FileSettingsStorage implements SettingsStorage {
private globalSettingsPath: string; private globalSettingsPath: string;
private projectSettingsPath: string; private projectSettingsPath: string;
constructor(cwd: string = process.cwd(), agentDir: string = getAgentDir()) { constructor(cwd: string, agentDir: string) {
this.globalSettingsPath = join(agentDir, "settings.json"); this.globalSettingsPath = join(agentDir, "settings.json");
this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "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 */ /** 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); const storage = new FileSettingsStorage(cwd, agentDir);
return SettingsManager.fromStorage(storage); return SettingsManager.fromStorage(storage);
} }

View File

@@ -374,14 +374,14 @@ function escapeXml(str: string): string {
} }
export interface LoadSkillsOptions { export interface LoadSkillsOptions {
/** Working directory for project-local skills. Default: process.cwd() */ /** Working directory for project-local skills. */
cwd?: string; cwd: string;
/** Agent config directory for global skills. Default: ~/.pi/agent */ /** Agent config directory for global skills. */
agentDir?: string; agentDir: string;
/** Explicit skill paths (files or directories) */ /** Explicit skill paths (files or directories) */
skillPaths?: string[]; skillPaths: string[];
/** Include default skills directories. Default: true */ /** Include default skills directories. */
includeDefaults?: boolean; includeDefaults: boolean;
} }
function normalizePath(input: string): string { function normalizePath(input: string): string {
@@ -401,8 +401,8 @@ function resolveSkillPath(p: string, cwd: string): string {
* Load skills from all configured locations. * Load skills from all configured locations.
* Returns skills and any validation diagnostics. * Returns skills and any validation diagnostics.
*/ */
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult { export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const { cwd = process.cwd(), agentDir, skillPaths = [], includeDefaults = true } = options; const { cwd, agentDir, skillPaths, includeDefaults } = options;
// Resolve agentDir - if not provided, use default from config // Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir(); const resolvedAgentDir = agentDir ?? getAgentDir();

View File

@@ -16,8 +16,8 @@ export interface BuildSystemPromptOptions {
promptGuidelines?: string[]; promptGuidelines?: string[];
/** Text to append to system prompt. */ /** Text to append to system prompt. */
appendSystemPrompt?: string; appendSystemPrompt?: string;
/** Working directory. Default: process.cwd() */ /** Working directory. */
cwd?: string; cwd: string;
/** Pre-loaded context files. */ /** Pre-loaded context files. */
contextFiles?: Array<{ path: string; content: string }>; contextFiles?: Array<{ path: string; content: string }>;
/** Pre-loaded skills. */ /** Pre-loaded skills. */
@@ -25,7 +25,7 @@ export interface BuildSystemPromptOptions {
} }
/** Build the system prompt with tools, guidelines, and context */ /** Build the system prompt with tools, guidelines, and context */
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
const { const {
customPrompt, customPrompt,
selectedTools, selectedTools,
@@ -36,7 +36,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
contextFiles: providedContextFiles, contextFiles: providedContextFiles,
skills: providedSkills, skills: providedSkills,
} = options; } = options;
const resolvedCwd = cwd ?? process.cwd(); const resolvedCwd = cwd;
const promptCwd = resolvedCwd.replace(/\\/g, "/"); const promptCwd = resolvedCwd.replace(/\\/g, "/");
const now = new Date(); const now = new Date();

View File

@@ -444,7 +444,3 @@ export function createBashToolDefinition(
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> { export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
return wrapToolDefinition(createBashToolDefinition(cwd, options)); 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());

View File

@@ -485,7 +485,3 @@ export function createEditToolDefinition(
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> { export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
return wrapToolDefinition(createEditToolDefinition(cwd, options)); 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());

View File

@@ -368,7 +368,3 @@ export function createFindToolDefinition(
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> { export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
return wrapToolDefinition(createFindToolDefinition(cwd, options)); 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());

View File

@@ -382,7 +382,3 @@ export function createGrepToolDefinition(
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> { export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
return wrapToolDefinition(createGrepToolDefinition(cwd, options)); 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());

View File

@@ -227,7 +227,3 @@ export function createLsToolDefinition(
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> { export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
return wrapToolDefinition(createLsToolDefinition(cwd, options)); 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());

View File

@@ -271,7 +271,3 @@ export function createReadToolDefinition(
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> { export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
return wrapToolDefinition(createReadToolDefinition(cwd, options)); 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());

View File

@@ -279,7 +279,3 @@ export function createWriteToolDefinition(
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> { export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
return wrapToolDefinition(createWriteToolDefinition(cwd, options)); 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());

View File

@@ -183,8 +183,6 @@ export {
createReadTool, createReadTool,
createWriteTool, createWriteTool,
type PromptTemplate, type PromptTemplate,
// Pre-built tools (use process.cwd())
readOnlyTools,
} from "./core/sdk.js"; } from "./core/sdk.js";
export { export {
type BranchSummaryEntry, type BranchSummaryEntry,
@@ -235,9 +233,6 @@ export {
type BashToolDetails, type BashToolDetails,
type BashToolInput, type BashToolInput,
type BashToolOptions, type BashToolOptions,
bashTool,
bashToolDefinition,
codingTools,
createBashToolDefinition, createBashToolDefinition,
createEditToolDefinition, createEditToolDefinition,
createFindToolDefinition, createFindToolDefinition,
@@ -252,33 +247,23 @@ export {
type EditToolDetails, type EditToolDetails,
type EditToolInput, type EditToolInput,
type EditToolOptions, type EditToolOptions,
editTool,
editToolDefinition,
type FindOperations, type FindOperations,
type FindToolDetails, type FindToolDetails,
type FindToolInput, type FindToolInput,
type FindToolOptions, type FindToolOptions,
findTool,
findToolDefinition,
formatSize, formatSize,
type GrepOperations, type GrepOperations,
type GrepToolDetails, type GrepToolDetails,
type GrepToolInput, type GrepToolInput,
type GrepToolOptions, type GrepToolOptions,
grepTool,
grepToolDefinition,
type LsOperations, type LsOperations,
type LsToolDetails, type LsToolDetails,
type LsToolInput, type LsToolInput,
type LsToolOptions, type LsToolOptions,
lsTool,
lsToolDefinition,
type ReadOperations, type ReadOperations,
type ReadToolDetails, type ReadToolDetails,
type ReadToolInput, type ReadToolInput,
type ReadToolOptions, type ReadToolOptions,
readTool,
readToolDefinition,
type ToolsOptions, type ToolsOptions,
type TruncationOptions, type TruncationOptions,
type TruncationResult, type TruncationResult,
@@ -289,8 +274,6 @@ export {
type WriteToolInput, type WriteToolInput,
type WriteToolOptions, type WriteToolOptions,
withFileMutationQueue, withFileMutationQueue,
writeTool,
writeToolDefinition,
} from "./core/tools/index.js"; } from "./core/tools/index.js";
// Main entry point // Main entry point
export { type MainOptions, main } from "./main.js"; export { type MainOptions, main } from "./main.js";

View File

@@ -301,7 +301,7 @@ export async function showDeprecationWarnings(warnings: string[]): Promise<void>
* *
* @returns Object with migration results and deprecation warnings * @returns Object with migration results and deprecation warnings
*/ */
export function runMigrations(cwd: string = process.cwd()): { export function runMigrations(cwd: string): {
migratedAuthProviders: string[]; migratedAuthProviders: string[];
deprecationWarnings: string[]; deprecationWarnings: string[];
} { } {

View File

@@ -1,6 +1,6 @@
import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; 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 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 { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
import { convertToPng } from "../../../utils/image-convert.js"; import { convertToPng } from "../../../utils/image-convert.js";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
@@ -45,14 +45,14 @@ export class ToolExecutionComponent extends Container {
options: ToolExecutionOptions = {}, options: ToolExecutionOptions = {},
toolDefinition: ToolDefinition<any, any> | undefined, toolDefinition: ToolDefinition<any, any> | undefined,
ui: TUI, ui: TUI,
cwd: string = process.cwd(), cwd: string,
) { ) {
super(); super();
this.toolName = toolName; this.toolName = toolName;
this.toolCallId = toolCallId; this.toolCallId = toolCallId;
this.args = args; this.args = args;
this.toolDefinition = toolDefinition; this.toolDefinition = toolDefinition;
this.builtInToolDefinition = allToolDefinitions[toolName as keyof typeof allToolDefinitions]; this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName];
this.showImages = options.showImages ?? true; this.showImages = options.showImages ?? true;
this.ui = ui; this.ui = ui;
this.cwd = cwd; this.cwd = cwd;

View File

@@ -53,7 +53,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig; return cachedShellConfig;
} }
const settings = SettingsManager.create(); const settings = SettingsManager.create(process.cwd());
const customShellPath = settings.getShellPath(); const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path // 1. Check user-specified shell path

View File

@@ -18,7 +18,7 @@ import { AuthStorage } from "../src/core/auth-storage.js";
import { ModelRegistry } from "../src/core/model-registry.js"; import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-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"; import { API_KEY, createTestResourceLoader } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
@@ -52,7 +52,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant. Be concise.", systemPrompt: "You are a helpful assistant. Be concise.",
tools: codingTools, tools: createCodingTools(process.cwd()),
}, },
}); });

View File

@@ -3,8 +3,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { executeBash } from "../src/core/bash-executor.js"; import { executeBashWithOperations } from "../src/core/bash-executor.js";
import { createBashTool } from "../src/core/tools/bash.js"; import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
function toBashSingleQuotedArg(value: string): string { function toBashSingleQuotedArg(value: string): string {
return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`; return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`;
@@ -87,9 +87,15 @@ describe.skipIf(process.platform !== "win32")("Windows child-process close handl
const controller = new AbortController(); const controller = new AbortController();
try { try {
const result = await withTimeout(executeBash(command, { signal: controller.signal }), 3000, () => { const result = await withTimeout(
controller.abort(); executeBashWithOperations(command, process.cwd(), createLocalBashOperations(), {
}); signal: controller.signal,
}),
3000,
() => {
controller.abort();
},
);
expect(result.output).toContain("child-exiting"); expect(result.output).toContain("child-exiting");
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);

View File

@@ -21,7 +21,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.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"; import { createTestResourceLoader } from "./utilities.js";
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
@@ -92,7 +92,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant. Be concise.", systemPrompt: "You are a helpful assistant. Be concise.",
tools: codingTools, tools: createCodingTools(process.cwd()),
}, },
}); });

View File

@@ -112,7 +112,7 @@ describe("FooterDataProvider reftable branch detection", () => {
mkdirSync(nestedDir, { recursive: true }); mkdirSync(nestedDir, { recursive: true });
process.chdir(nestedDir); process.chdir(nestedDir);
const provider = new FooterDataProvider(); const provider = new FooterDataProvider(nestedDir);
try { try {
expect(provider.getGitBranch()).toBe("main"); expect(provider.getGitBranch()).toBe("main");
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled(); expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
@@ -125,7 +125,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const repoDir = createPlainReftableRepo(tempDir); const repoDir = createPlainReftableRepo(tempDir);
process.chdir(repoDir); process.chdir(repoDir);
const provider = new FooterDataProvider(); const provider = new FooterDataProvider(repoDir);
try { try {
expect(provider.getGitBranch()).toBe("main"); expect(provider.getGitBranch()).toBe("main");
expect(vi.mocked(spawnSync)).toHaveBeenCalledWith( expect(vi.mocked(spawnSync)).toHaveBeenCalledWith(
@@ -146,7 +146,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir } = createReftableWorktree(tempDir); const { worktreeDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir); process.chdir(worktreeDir);
const provider = new FooterDataProvider(); const provider = new FooterDataProvider(worktreeDir);
try { try {
expect(provider.getGitBranch()).toBe("main"); expect(provider.getGitBranch()).toBe("main");
} finally { } finally {
@@ -159,7 +159,7 @@ describe("FooterDataProvider reftable branch detection", () => {
process.chdir(repoDir); process.chdir(repoDir);
resolvedBranch = ""; resolvedBranch = "";
const provider = new FooterDataProvider(); const provider = new FooterDataProvider(repoDir);
try { try {
expect(provider.getGitBranch()).toBe("detached"); expect(provider.getGitBranch()).toBe("detached");
} finally { } finally {
@@ -171,7 +171,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir); const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir); process.chdir(worktreeDir);
const provider = new FooterDataProvider(); const provider = new FooterDataProvider(worktreeDir);
try { try {
expect(provider.getGitBranch()).toBe("main"); expect(provider.getGitBranch()).toBe("main");
vi.mocked(spawnSync).mockClear(); vi.mocked(spawnSync).mockClear();
@@ -194,7 +194,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir); const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir); process.chdir(worktreeDir);
const provider = new FooterDataProvider(); const provider = new FooterDataProvider(worktreeDir);
try { try {
expect(provider.getGitBranch()).toBe("main"); expect(provider.getGitBranch()).toBe("main");
vi.mocked(execFile).mockClear(); vi.mocked(execFile).mockClear();
@@ -215,7 +215,7 @@ describe("FooterDataProvider reftable branch detection", () => {
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir); const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
process.chdir(worktreeDir); process.chdir(worktreeDir);
const provider = new FooterDataProvider(); const provider = new FooterDataProvider(worktreeDir);
try { try {
expect(provider.getGitBranch()).toBe("main"); expect(provider.getGitBranch()).toBe("main");
resolvedBranch = "foo"; resolvedBranch = "foo";

View File

@@ -12,6 +12,7 @@ import { mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { afterAll, describe, expect, test } from "vitest"; import { afterAll, describe, expect, test } from "vitest";
import { getAgentDir } from "../src/config.js";
import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.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({ const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir], promptPaths: [testDir],
includeDefaults: false, includeDefaults: false,
}); });
@@ -427,6 +430,8 @@ Wrap it. Additional instructions: $ARGUMENTS`,
); );
const templates = loadPromptTemplates({ const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir], promptPaths: [testDir],
includeDefaults: false, includeDefaults: false,
}); });
@@ -447,6 +452,8 @@ Audit changelog entries for all commits since the last release.`,
); );
const templates = loadPromptTemplates({ const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir], promptPaths: [testDir],
includeDefaults: false, includeDefaults: false,
}); });
@@ -467,6 +474,8 @@ Do something`,
); );
const templates = loadPromptTemplates({ const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir], promptPaths: [testDir],
includeDefaults: false, includeDefaults: false,
}); });
@@ -487,6 +496,8 @@ Analyze GitHub issue(s): $ARGUMENTS`,
); );
const templates = loadPromptTemplates({ const templates = loadPromptTemplates({
cwd: process.cwd(),
agentDir: getAgentDir(),
promptPaths: [testDir], promptPaths: [testDir],
includeDefaults: false, includeDefaults: false,
}); });

View File

@@ -354,6 +354,7 @@ describe("skills", () => {
agentDir: emptyAgentDir, agentDir: emptyAgentDir,
cwd: emptyCwd, cwd: emptyCwd,
skillPaths: [join(fixturesDir, "valid-skill")], skillPaths: [join(fixturesDir, "valid-skill")],
includeDefaults: true,
}); });
expect(skills).toHaveLength(1); expect(skills).toHaveLength(1);
expect(skills[0].sourceInfo.scope).toBe("temporary"); expect(skills[0].sourceInfo.scope).toBe("temporary");
@@ -365,6 +366,7 @@ describe("skills", () => {
agentDir: emptyAgentDir, agentDir: emptyAgentDir,
cwd: emptyCwd, cwd: emptyCwd,
skillPaths: ["/non/existent/path"], skillPaths: ["/non/existent/path"],
includeDefaults: true,
}); });
expect(skills).toHaveLength(0); expect(skills).toHaveLength(0);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not exist"))).toBe(true); expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not exist"))).toBe(true);
@@ -376,11 +378,13 @@ describe("skills", () => {
agentDir: emptyAgentDir, agentDir: emptyAgentDir,
cwd: emptyCwd, cwd: emptyCwd,
skillPaths: ["~/.pi/agent/skills"], skillPaths: ["~/.pi/agent/skills"],
includeDefaults: true,
}); });
const { skills: withoutTilde } = loadSkills({ const { skills: withoutTilde } = loadSkills({
agentDir: emptyAgentDir, agentDir: emptyAgentDir,
cwd: emptyCwd, cwd: emptyCwd,
skillPaths: [homeSkillsDir], skillPaths: [homeSkillsDir],
includeDefaults: true,
}); });
expect(withTilde.length).toBe(withoutTilde.length); expect(withTilde.length).toBe(withoutTilde.length);
}); });

View File

@@ -8,6 +8,7 @@ describe("buildSystemPrompt", () => {
selectedTools: [], selectedTools: [],
contextFiles: [], contextFiles: [],
skills: [], skills: [],
cwd: process.cwd(),
}); });
expect(prompt).toContain("Available tools:\n(none)"); expect(prompt).toContain("Available tools:\n(none)");
@@ -18,6 +19,7 @@ describe("buildSystemPrompt", () => {
selectedTools: [], selectedTools: [],
contextFiles: [], contextFiles: [],
skills: [], skills: [],
cwd: process.cwd(),
}); });
expect(prompt).toContain("Show file paths clearly"); expect(prompt).toContain("Show file paths clearly");
@@ -35,6 +37,7 @@ describe("buildSystemPrompt", () => {
}, },
contextFiles: [], contextFiles: [],
skills: [], skills: [],
cwd: process.cwd(),
}); });
expect(prompt).toContain("- read:"); expect(prompt).toContain("- read:");
@@ -53,6 +56,7 @@ describe("buildSystemPrompt", () => {
}, },
contextFiles: [], contextFiles: [],
skills: [], skills: [],
cwd: process.cwd(),
}); });
expect(prompt).toContain("- dynamic_tool: Run dynamic test behavior"); expect(prompt).toContain("- dynamic_tool: Run dynamic test behavior");
@@ -63,6 +67,7 @@ describe("buildSystemPrompt", () => {
selectedTools: ["read", "dynamic_tool"], selectedTools: ["read", "dynamic_tool"],
contextFiles: [], contextFiles: [],
skills: [], skills: [],
cwd: process.cwd(),
}); });
expect(prompt).not.toContain("dynamic_tool"); expect(prompt).not.toContain("dynamic_tool");
@@ -76,6 +81,7 @@ describe("buildSystemPrompt", () => {
promptGuidelines: ["Use dynamic_tool for project summaries."], promptGuidelines: ["Use dynamic_tool for project summaries."],
contextFiles: [], contextFiles: [],
skills: [], skills: [],
cwd: process.cwd(),
}); });
expect(prompt).toContain("- Use dynamic_tool for project summaries."); 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. ", " "], promptGuidelines: ["Use dynamic_tool for summaries.", " Use dynamic_tool for summaries. ", " "],
contextFiles: [], contextFiles: [],
skills: [], skills: [],
cwd: process.cwd(),
}); });
expect(prompt.match(/- Use dynamic_tool for summaries\./g)).toHaveLength(1); expect(prompt.match(/- Use dynamic_tool for summaries\./g)).toHaveLength(1);

View File

@@ -40,7 +40,15 @@ describe("ToolExecutionComponent parity", () => {
renderResult: () => new Text("custom result", 0, 0), 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"); expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
component.updateResult( component.updateResult(
@@ -69,6 +77,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
overrideDefinition, overrideDefinition,
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false }); component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false });
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
@@ -85,6 +94,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
undefined, undefined,
createFakeTui(), createFakeTui(),
process.cwd(),
); );
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read"); expect(rendered).toContain("read");
@@ -119,6 +129,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
createReadToolDefinition(process.cwd()), createReadToolDefinition(process.cwd()),
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
@@ -138,6 +149,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
overrideDefinition, overrideDefinition,
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
@@ -158,6 +170,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
overrideDefinition, overrideDefinition,
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
@@ -179,6 +192,7 @@ describe("ToolExecutionComponent parity", () => {
renderResult: () => new Text("override result", 0, 0), renderResult: () => new Text("override result", 0, 0),
}, },
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
@@ -201,6 +215,7 @@ describe("ToolExecutionComponent parity", () => {
renderResult: () => new Text("wrapped override result", 0, 0), renderResult: () => new Text("wrapped override result", 0, 0),
}, },
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); 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); component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom call shared-token"); expect(rendered).toContain("custom call shared-token");
@@ -243,6 +266,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
toolDefinition, toolDefinition,
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
@@ -261,6 +285,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
toolDefinition, toolDefinition,
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
@@ -276,6 +301,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
createWriteToolDefinition(process.cwd()), createWriteToolDefinition(process.cwd()),
createFakeTui(), createFakeTui(),
process.cwd(),
); );
const rendered = stripAnsi(component.render(120).join("\n")); const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("one"); expect(rendered).toContain("one");
@@ -291,6 +317,7 @@ describe("ToolExecutionComponent parity", () => {
{}, {},
createReadToolDefinition(process.cwd()), createReadToolDefinition(process.cwd()),
createFakeTui(), createFakeTui(),
process.cwd(),
); );
component.updateResult( component.updateResult(
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false }, { content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },

View File

@@ -2,16 +2,26 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeBash } from "../src/core/bash-executor.js"; import { executeBashWithOperations } from "../src/core/bash-executor.js";
import { bashTool, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js"; import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { editTool } from "../src/core/tools/edit.js"; import {
import { findTool } from "../src/core/tools/find.js"; createEditTool,
import { grepTool } from "../src/core/tools/grep.js"; createFindTool,
import { lsTool } from "../src/core/tools/ls.js"; createGrepTool,
import { readTool } from "../src/core/tools/read.js"; createLsTool,
import { writeTool } from "../src/core/tools/write.js"; createReadTool,
createWriteTool,
} from "../src/index.js";
import * as shellModule from "../src/utils/shell.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 // Helper to extract text from content blocks
function getTextOutput(result: any): string { function getTextOutput(result: any): string {
return ( return (
@@ -438,7 +448,11 @@ describe("Coding Agent Tools", () => {
}); });
it("should preserve executeBash sanitization when using local bash operations", async () => { 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.exitCode).toBe(0);
expect(result.output).toBe("red\n"); 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 () => { 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; const fullOutputPath = result.fullOutputPath;
expect(result.truncated).toBe(true); expect(result.truncated).toBe(true);

View File

@@ -17,7 +17,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import type { ResourceLoader } from "../src/core/resource-loader.js"; import type { ResourceLoader } from "../src/core/resource-loader.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-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 * API key for authenticated tests. Tests using this should be wrapped in
@@ -242,7 +242,7 @@ export function createTestSession(options: TestSessionOptions = {}): TestSession
initialState: { initialState: {
model, model,
systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.", systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.",
tools: codingTools, tools: createCodingTools(process.cwd()),
}, },
}); });

View File

@@ -265,11 +265,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
private basePath: string; private basePath: string;
private fdPath: string | null; private fdPath: string | null;
constructor( constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) {
commands: (SlashCommand | AutocompleteItem)[] = [],
basePath: string = process.cwd(),
fdPath: string | null = null,
) {
this.commands = commands; this.commands = commands;
this.basePath = basePath; this.basePath = basePath;
this.fdPath = fdPath; this.fdPath = fdPath;

View File

@@ -20,6 +20,7 @@ export class Loader extends Text {
private currentFrame = 0; private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null; private ui: TUI | null = null;
private renderIndicatorVerbatim = false;
constructor( constructor(
ui: TUI, ui: TUI,
@@ -55,7 +56,8 @@ export class Loader extends Text {
} }
setIndicator(indicator?: LoaderIndicatorOptions): void { 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.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;
this.currentFrame = 0; this.currentFrame = 0;
this.start(); this.start();
@@ -74,7 +76,8 @@ export class Loader extends Text {
private updateDisplay(): void { private updateDisplay(): void {
const frame = this.frames[this.currentFrame] ?? ""; 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)}`); this.setText(`${indicator}${this.messageColorFn(this.message)}`);
if (this.ui) { if (this.ui) {
this.ui.requestRender(); this.ui.requestRender();

View File

@@ -2476,14 +2476,17 @@ describe("Editor component", () => {
it("awaits async slash command argument completions", async () => { it("awaits async slash command argument completions", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme); const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([ const provider = new CombinedAutocompleteProvider(
{ [
name: "load-skills", {
description: "Load skills", name: "load-skills",
getArgumentCompletions: async (prefix) => description: "Load skills",
prefix.startsWith("s") ? [{ value: "skill-a", label: "skill-a" }] : null, getArgumentCompletions: async (prefix) =>
}, prefix.startsWith("s") ? [{ value: "skill-a", label: "skill-a" }] : null,
]); },
],
process.cwd(),
);
editor.setAutocompleteProvider(provider); editor.setAutocompleteProvider(provider);
editor.setText("/load-skills "); editor.setText("/load-skills ");
@@ -2498,15 +2501,18 @@ describe("Editor component", () => {
it("ignores invalid slash command argument completion results", async () => { it("ignores invalid slash command argument completion results", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme); const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([ const provider = new CombinedAutocompleteProvider(
{ [
name: "load-skills", {
description: "Load skills", name: "load-skills",
getArgumentCompletions: (() => "not-an-array") as unknown as ( description: "Load skills",
argumentPrefix: string, getArgumentCompletions: (() => "not-an-array") as unknown as (
) => Promise<{ value: string; label: string }[] | null>, argumentPrefix: string,
}, ) => Promise<{ value: string; label: string }[] | null>,
]); },
],
process.cwd(),
);
editor.setAutocompleteProvider(provider); editor.setAutocompleteProvider(provider);
editor.setText("/load-skills "); editor.setText("/load-skills ");
@@ -2518,14 +2524,17 @@ describe("Editor component", () => {
it("does not show argument completions when command has no argument completer", async () => { it("does not show argument completions when command has no argument completer", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme); const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([ const provider = new CombinedAutocompleteProvider(
{ name: "help", description: "Show help" }, [
{ { name: "help", description: "Show help" },
name: "model", {
description: "Switch model", name: "model",
getArgumentCompletions: () => [{ value: "claude-opus", label: "claude-opus" }], description: "Switch model",
}, getArgumentCompletions: () => [{ value: "claude-opus", label: "claude-opus" }],
]); },
],
process.cwd(),
);
editor.setAutocompleteProvider(provider); editor.setAutocompleteProvider(provider);
editor.handleInput("/"); editor.handleInput("/");