From 5482bf3e147a5639dc95f5a10bf630ced5840098 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 00:47:16 +0100 Subject: [PATCH 01/25] Add SDK for programmatic AgentSession usage - Add src/core/sdk.ts with createAgentSession() factory and discovery functions - Update loaders to accept cwd/agentDir parameters (skills, hooks, custom-tools, slash-commands, system-prompt) - Export SDK from package index Addresses #272 --- .../src/core/custom-tools/loader.ts | 9 +- .../coding-agent/src/core/hooks/loader.ts | 25 +- packages/coding-agent/src/core/sdk.ts | 593 ++++++++++++++++++ packages/coding-agent/src/core/skills.ts | 22 +- .../coding-agent/src/core/slash-commands.ts | 25 +- .../coding-agent/src/core/system-prompt.ts | 70 ++- packages/coding-agent/src/core/tools/index.ts | 10 +- packages/coding-agent/src/index.ts | 23 + 8 files changed, 737 insertions(+), 40 deletions(-) create mode 100644 packages/coding-agent/src/core/sdk.ts diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 82ccbedc..7af81acd 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -303,7 +303,7 @@ function discoverToolsInDir(dir: string): string[] { /** * Discover and load tools from standard locations: - * 1. ~/.pi/agent/tools/*.ts (global) + * 1. agentDir/tools/*.ts (global) * 2. cwd/.pi/tools/*.ts (project-local) * * Plus any explicitly configured paths from settings or CLI. @@ -311,12 +311,15 @@ function discoverToolsInDir(dir: string): string[] { * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags * @param cwd - Current working directory * @param builtInToolNames - Names of built-in tools to check for conflicts + * @param agentDir - Agent config directory. Default: from getAgentDir() */ export async function discoverAndLoadCustomTools( configuredPaths: string[], cwd: string, builtInToolNames: string[], + agentDir?: string, ): Promise { + const resolvedAgentDir = agentDir ?? getAgentDir(); const allPaths: string[] = []; const seen = new Set(); @@ -331,8 +334,8 @@ export async function discoverAndLoadCustomTools( } }; - // 1. Global tools: ~/.pi/agent/tools/ - const globalToolsDir = path.join(getAgentDir(), "tools"); + // 1. Global tools: agentDir/tools/ + const globalToolsDir = path.join(resolvedAgentDir, "tools"); addPaths(discoverToolsInDir(globalToolsDir)); // 2. Project-local tools: cwd/.pi/tools/ diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 50cef601..4f5273c0 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -215,17 +215,28 @@ function discoverHooksInDir(dir: string): string[] { } } +export interface DiscoverAndLoadHooksOptions { + /** Explicit paths from settings.json or CLI */ + configuredPaths?: string[]; + /** Current working directory */ + cwd?: string; + /** Agent config directory. Default: from getAgentDir() */ + agentDir?: string; +} + /** * Discover and load hooks from standard locations: - * 1. ~/.pi/agent/hooks/*.ts (global) + * 1. agentDir/hooks/*.ts (global) * 2. cwd/.pi/hooks/*.ts (project-local) * * Plus any explicitly configured paths from settings. - * - * @param configuredPaths - Explicit paths from settings.json - * @param cwd - Current working directory */ -export async function discoverAndLoadHooks(configuredPaths: string[], cwd: string): Promise { +export async function discoverAndLoadHooks( + configuredPaths: string[], + cwd: string, + agentDir?: string, +): Promise { + const resolvedAgentDir = agentDir ?? getAgentDir(); const allPaths: string[] = []; const seen = new Set(); @@ -240,8 +251,8 @@ export async function discoverAndLoadHooks(configuredPaths: string[], cwd: strin } }; - // 1. Global hooks: ~/.pi/agent/hooks/ - const globalHooksDir = path.join(getAgentDir(), "hooks"); + // 1. Global hooks: agentDir/hooks/ + const globalHooksDir = path.join(resolvedAgentDir, "hooks"); addPaths(discoverHooksInDir(globalHooksDir)); // 2. Project-local hooks: cwd/.pi/hooks/ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts new file mode 100644 index 00000000..9bae188f --- /dev/null +++ b/packages/coding-agent/src/core/sdk.ts @@ -0,0 +1,593 @@ +/** + * SDK for programmatic usage of AgentSession. + * + * Provides a factory function and discovery helpers that allow full control + * over agent configuration, or sensible defaults that match CLI behavior. + * + * @example + * ```typescript + * // Minimal - everything auto-discovered + * const session = await createAgentSession(); + * + * // With custom hooks + * const session = await createAgentSession({ + * hooks: [ + * ...await discoverHooks(), + * { factory: myHookFactory }, + * ], + * }); + * + * // Full control + * const session = await createAgentSession({ + * model: myModel, + * getApiKey: async () => process.env.MY_KEY, + * tools: [readTool, bashTool], + * hooks: [], + * skills: [], + * sessionFile: false, + * }); + * ``` + */ + +import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Model } from "@mariozechner/pi-ai"; +import { getAgentDir } from "../config.js"; +import { AgentSession } from "./agent-session.js"; +import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js"; +import type { CustomAgentTool } from "./custom-tools/types.js"; +import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; +import type { HookFactory } from "./hooks/types.js"; +import { messageTransformer } from "./messages.js"; +import { + findModel as findModelInternal, + getApiKeyForModel, + getAvailableModels, + loadAndMergeModels, +} from "./model-config.js"; +import { SessionManager } from "./session-manager.js"; +import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js"; +import { loadSkills as loadSkillsInternal, type Skill } from "./skills.js"; +import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands.js"; +import { + buildSystemPrompt as buildSystemPromptInternal, + loadProjectContextFiles as loadContextFilesInternal, +} from "./system-prompt.js"; +import { + allTools, + bashTool, + codingTools, + editTool, + findTool, + grepTool, + lsTool, + readOnlyTools, + readTool, + type Tool, + writeTool, +} from "./tools/index.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CreateAgentSessionOptions { + // === Environment === + /** Working directory for project-local discovery. Default: process.cwd() */ + cwd?: string; + /** Global config directory. Default: ~/.pi/agent */ + agentDir?: string; + + // === Model & Thinking === + /** Model to use. Default: from settings, else first available */ + model?: Model; + /** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */ + thinkingLevel?: ThinkingLevel; + + // === API Key === + /** API key resolver. Default: defaultGetApiKey() */ + getApiKey?: (model: Model) => Promise; + + // === System Prompt === + /** System prompt. String replaces default, function receives default and returns final. */ + systemPrompt?: string | ((defaultPrompt: string) => string); + + // === Tools === + /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ + tools?: Tool[]; + /** Custom tools. Default: discovered from cwd/.pi/tools/ + agentDir/tools/ */ + customTools?: Array<{ path?: string; tool: CustomAgentTool }>; + + // === Hooks === + /** Hooks. Default: discovered from cwd/.pi/hooks/ + agentDir/hooks/ */ + hooks?: Array<{ path?: string; factory: HookFactory }>; + + // === Context === + /** Skills. Default: discovered from multiple locations */ + skills?: Skill[]; + /** Context files (AGENTS.md content). Default: discovered walking up from cwd */ + contextFiles?: Array<{ path: string; content: string }>; + /** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */ + slashCommands?: FileSlashCommand[]; + + // === Session === + /** Session file path, or false to disable persistence. Default: auto in agentDir/sessions/ */ + sessionFile?: string | false; + + // === Settings === + /** Settings overrides (merged with agentDir/settings.json) */ + settings?: Partial; +} + +// ============================================================================ +// Re-exports +// ============================================================================ + +export type { CustomAgentTool } from "./custom-tools/types.js"; +export type { HookAPI, HookFactory } from "./hooks/types.js"; +export type { Settings, SkillsSettings } from "./settings-manager.js"; +export type { Skill } from "./skills.js"; +export type { FileSlashCommand } from "./slash-commands.js"; +export type { Tool } from "./tools/index.js"; + +export { + readTool, + bashTool, + editTool, + writeTool, + grepTool, + findTool, + lsTool, + codingTools, + readOnlyTools, + allTools as allBuiltInTools, +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getDefaultAgentDir(): string { + return getAgentDir(); +} + +// ============================================================================ +// Discovery Functions +// ============================================================================ + +/** + * Get all models (built-in + custom from models.json). + * Note: Uses default agentDir for models.json location. + */ +export function discoverModels(): Model[] { + const { models, error } = loadAndMergeModels(); + if (error) { + throw new Error(error); + } + return models; +} + +/** + * Get models that have valid API keys available. + * Note: Uses default agentDir for models.json and oauth.json location. + */ +export async function discoverAvailableModels(): Promise[]> { + const { models, error } = await getAvailableModels(); + if (error) { + throw new Error(error); + } + return models; +} + +/** + * Find a model by provider and ID. + * Note: Uses default agentDir for models.json location. + * @returns The model, or null if not found + */ +export function findModel(provider: string, modelId: string): Model | null { + const { model, error } = findModelInternal(provider, modelId); + if (error) { + throw new Error(error); + } + return model; +} + +/** + * Discover hooks from cwd and agentDir. + */ +export async function discoverHooks( + cwd?: string, + agentDir?: string, +): Promise> { + const resolvedCwd = cwd ?? process.cwd(); + const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); + + const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir); + + // Log errors but don't fail + for (const { path, error } of errors) { + console.error(`Failed to load hook "${path}": ${error}`); + } + + return hooks.map((h) => ({ + path: h.path, + factory: createFactoryFromLoadedHook(h), + })); +} + +/** + * Discover custom tools from cwd and agentDir. + */ +export async function discoverCustomTools( + cwd?: string, + agentDir?: string, +): Promise> { + const resolvedCwd = cwd ?? process.cwd(); + const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); + + const { tools, errors } = await discoverAndLoadCustomTools([], resolvedCwd, Object.keys(allTools), resolvedAgentDir); + + // Log errors but don't fail + for (const { path, error } of errors) { + console.error(`Failed to load custom tool "${path}": ${error}`); + } + + return tools.map((t) => ({ + path: t.path, + tool: t.tool, + })); +} + +/** + * Discover skills from cwd and agentDir. + */ +export function discoverSkills(cwd?: string, agentDir?: string, settings?: SkillsSettings): Skill[] { + const { skills } = loadSkillsInternal({ + ...settings, + cwd: cwd ?? process.cwd(), + agentDir: agentDir ?? getDefaultAgentDir(), + }); + return skills; +} + +/** + * Discover context files (AGENTS.md) walking up from cwd. + */ +export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ path: string; content: string }> { + return loadContextFilesInternal({ + cwd: cwd ?? process.cwd(), + agentDir: agentDir ?? getDefaultAgentDir(), + }); +} + +/** + * Discover slash commands from cwd and agentDir. + */ +export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlashCommand[] { + return loadSlashCommandsInternal({ + cwd: cwd ?? process.cwd(), + agentDir: agentDir ?? getDefaultAgentDir(), + }); +} + +// ============================================================================ +// API Key Helpers +// ============================================================================ + +/** + * Create the default API key resolver. + * Checks custom providers (models.json), OAuth, and environment variables. + * Note: Uses default agentDir for models.json and oauth.json location. + */ +export function defaultGetApiKey(): (model: Model) => Promise { + return getApiKeyForModel; +} + +// ============================================================================ +// System Prompt +// ============================================================================ + +export interface BuildSystemPromptOptions { + tools?: Tool[]; + skills?: Skill[]; + contextFiles?: Array<{ path: string; content: string }>; + cwd?: string; + appendPrompt?: string; +} + +/** + * Build the default system prompt. + */ +export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { + return buildSystemPromptInternal({ + cwd: options.cwd, + skills: options.skills, + contextFiles: options.contextFiles, + appendSystemPrompt: options.appendPrompt, + }); +} + +// ============================================================================ +// Settings +// ============================================================================ + +/** + * Load settings from agentDir/settings.json. + */ +export function loadSettings(agentDir?: string): Settings { + const manager = new SettingsManager(agentDir ?? getDefaultAgentDir()); + return { + defaultProvider: manager.getDefaultProvider(), + defaultModel: manager.getDefaultModel(), + defaultThinkingLevel: manager.getDefaultThinkingLevel(), + queueMode: manager.getQueueMode(), + theme: manager.getTheme(), + compaction: manager.getCompactionSettings(), + retry: manager.getRetrySettings(), + hideThinkingBlock: manager.getHideThinkingBlock(), + shellPath: manager.getShellPath(), + collapseChangelog: manager.getCollapseChangelog(), + hooks: manager.getHookPaths(), + hookTimeout: manager.getHookTimeout(), + customTools: manager.getCustomToolPaths(), + skills: manager.getSkillsSettings(), + terminal: { showImages: manager.getShowImages() }, + }; +} + +// ============================================================================ +// Internal Helpers +// ============================================================================ + +/** + * Create a HookFactory from a LoadedHook. + * This allows mixing discovered hooks with inline hooks. + */ +function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { + return (api) => { + for (const [eventType, handlers] of loaded.handlers) { + for (const handler of handlers) { + api.on(eventType as any, handler as any); + } + } + }; +} + +/** + * Convert hook definitions to LoadedHooks for the HookRunner. + */ +function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { + return definitions.map((def) => { + const handlers = new Map Promise>>(); + let sendHandler: (text: string, attachments?: any[]) => void = () => {}; + + const api = { + on: (event: string, handler: (...args: unknown[]) => Promise) => { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + send: (text: string, attachments?: any[]) => { + sendHandler(text, attachments); + }, + }; + + def.factory(api as any); + + return { + path: def.path ?? "", + resolvedPath: def.path ?? "", + handlers, + setSendHandler: (handler: (text: string, attachments?: any[]) => void) => { + sendHandler = handler; + }, + }; + }); +} + +// ============================================================================ +// Factory +// ============================================================================ + +/** + * Create an AgentSession with the specified options. + * + * @example + * ```typescript + * // Minimal - uses defaults + * const session = await createAgentSession(); + * + * // With explicit model + * const session = await createAgentSession({ + * model: findModel('anthropic', 'claude-sonnet-4-20250514'), + * thinkingLevel: 'high', + * }); + * + * // Full control + * const session = await createAgentSession({ + * model: myModel, + * getApiKey: async () => process.env.MY_KEY, + * systemPrompt: 'You are helpful.', + * tools: [readTool, bashTool], + * hooks: [], + * skills: [], + * sessionFile: false, + * }); + * ``` + */ +export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const agentDir = options.agentDir ?? getDefaultAgentDir(); + + // === Settings === + const settingsManager = new SettingsManager(agentDir); + + // === Model Resolution === + let model = options.model; + if (!model) { + // Try settings default + const defaultProvider = settingsManager.getDefaultProvider(); + const defaultModelId = settingsManager.getDefaultModel(); + if (defaultProvider && defaultModelId) { + model = findModel(defaultProvider, defaultModelId) ?? undefined; + // Verify it has an API key + if (model) { + const key = await getApiKeyForModel(model); + if (!key) { + model = undefined; + } + } + } + + // Fall back to first available + if (!model) { + const available = await discoverAvailableModels(); + if (available.length === 0) { + throw new Error( + "No models available. Set an API key environment variable " + + "(ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) or provide a model explicitly.", + ); + } + model = available[0]; + } + } + + // === Thinking Level Resolution === + let thinkingLevel = options.thinkingLevel; + if (thinkingLevel === undefined) { + thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off"; + } + // Clamp to model capabilities + if (!model.reasoning) { + thinkingLevel = "off"; + } + + // === API Key Resolver === + const getApiKey = options.getApiKey ?? defaultGetApiKey(); + + // === Skills === + const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings()); + + // === Context Files === + const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir); + + // === Tools === + const builtInTools = options.tools ?? codingTools; + + // === Custom Tools === + let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void }; + if (options.customTools !== undefined) { + // Use provided custom tools + const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({ + path: ct.path ?? "", + resolvedPath: ct.path ?? "", + tool: ct.tool, + })); + customToolsResult = { + tools: loadedTools, + setUIContext: () => {}, + }; + } else { + // Discover custom tools + const result = await discoverAndLoadCustomTools( + settingsManager.getCustomToolPaths(), + cwd, + Object.keys(allTools), + agentDir, + ); + for (const { path, error } of result.errors) { + console.error(`Failed to load custom tool "${path}": ${error}`); + } + customToolsResult = result; + } + + // === Hooks === + let hookRunner: HookRunner | null = null; + if (options.hooks !== undefined) { + if (options.hooks.length > 0) { + const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); + hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout()); + } + } else { + // Discover hooks + const { hooks, errors } = await discoverAndLoadHooks(settingsManager.getHookPaths(), cwd, agentDir); + for (const { path, error } of errors) { + console.error(`Failed to load hook "${path}": ${error}`); + } + if (hooks.length > 0) { + hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout()); + } + } + + // === Combine and wrap tools === + let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)]; + if (hookRunner) { + allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; + } + + // === System Prompt === + let systemPrompt: string; + const defaultPrompt = buildSystemPromptInternal({ + cwd, + agentDir, + skills, + contextFiles, + }); + + if (options.systemPrompt === undefined) { + systemPrompt = defaultPrompt; + } else if (typeof options.systemPrompt === "string") { + systemPrompt = options.systemPrompt; + } else { + systemPrompt = options.systemPrompt(defaultPrompt); + } + + // === Slash Commands === + const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir); + + // === Session Manager === + const sessionManager = new SessionManager(false, undefined); + if (options.sessionFile === false) { + sessionManager.disable(); + } else if (typeof options.sessionFile === "string") { + sessionManager.setSessionFile(options.sessionFile); + } + // If undefined, SessionManager uses auto-detection based on cwd + + // === Create Agent === + const agent = new Agent({ + initialState: { + systemPrompt, + model, + thinkingLevel, + tools: allToolsArray, + }, + messageTransformer, + queueMode: settingsManager.getQueueMode(), + transport: new ProviderTransport({ + getApiKey: async () => { + const currentModel = agent.state.model; + if (!currentModel) { + throw new Error("No model selected"); + } + const key = await getApiKey(currentModel); + if (!key) { + throw new Error(`No API key found for provider "${currentModel.provider}"`); + } + return key; + }, + }), + }); + + // === Create Session === + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + fileCommands: slashCommands, + hookRunner, + customTools: customToolsResult.tools, + skillsSettings: settingsManager.getSkillsSettings(), + }); + + return session; +} diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 260e3583..c388d32b 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "fs"; import { minimatch } from "minimatch"; import { homedir } from "os"; import { basename, dirname, join, resolve } from "path"; -import { CONFIG_DIR_NAME } from "../config.js"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import type { SkillsSettings } from "./settings-manager.js"; /** @@ -313,12 +313,21 @@ function escapeXml(str: string): string { .replace(/'/g, "'"); } +export interface LoadSkillsOptions extends SkillsSettings { + /** Working directory for project-local skills. Default: process.cwd() */ + cwd?: string; + /** Agent config directory for global skills. Default: ~/.pi/agent */ + agentDir?: string; +} + /** * Load skills from all configured locations. * Returns skills and any validation warnings. */ -export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult { +export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult { const { + cwd = process.cwd(), + agentDir, enableCodexUser = true, enableClaudeUser = true, enableClaudeProject = true, @@ -329,6 +338,9 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult { includeSkills = [], } = options; + // Resolve agentDir - if not provided, use default from config + const resolvedAgentDir = agentDir ?? getAgentDir(); + const skillMap = new Map(); const allWarnings: SkillWarning[] = []; const collisionWarnings: SkillWarning[] = []; @@ -375,13 +387,13 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult { addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude")); } if (enableClaudeProject) { - addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), ".claude", "skills"), "claude-project", "claude")); + addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude")); } if (enablePiUser) { - addSkills(loadSkillsFromDirInternal(join(homedir(), CONFIG_DIR_NAME, "agent", "skills"), "user", "recursive")); + addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive")); } if (enablePiProject) { - addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), CONFIG_DIR_NAME, "skills"), "project", "recursive")); + addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive")); } for (const customDir of customDirectories) { addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive")); diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 9c599c83..25f48063 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -165,20 +165,31 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st return commands; } +export interface LoadSlashCommandsOptions { + /** Working directory for project-local commands. Default: process.cwd() */ + cwd?: string; + /** Agent config directory for global commands. Default: from getCommandsDir() */ + agentDir?: string; +} + /** * Load all custom slash commands from: - * 1. Global: ~/{CONFIG_DIR_NAME}/agent/commands/ - * 2. Project: ./{CONFIG_DIR_NAME}/commands/ + * 1. Global: agentDir/commands/ + * 2. Project: cwd/{CONFIG_DIR_NAME}/commands/ */ -export function loadSlashCommands(): FileSlashCommand[] { +export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] { + const resolvedCwd = options.cwd ?? process.cwd(); + const resolvedAgentDir = options.agentDir ?? getCommandsDir(); + const commands: FileSlashCommand[] = []; - // 1. Load global commands from ~/{CONFIG_DIR_NAME}/agent/commands/ - const globalCommandsDir = getCommandsDir(); + // 1. Load global commands from agentDir/commands/ + // Note: if agentDir is provided, it should be the agent dir, not the commands dir + const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir; commands.push(...loadCommandsFromDir(globalCommandsDir, "user")); - // 2. Load project commands from ./{CONFIG_DIR_NAME}/commands/ - const projectCommandsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "commands"); + // 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/ + const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands"); commands.push(...loadCommandsFromDir(projectCommandsDir, "project")); return commands; diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 4b47dd62..5a449cd8 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -7,7 +7,7 @@ import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; import { getAgentDir, getDocsPath, getReadmePath } from "../config.js"; import type { SkillsSettings } from "./settings-manager.js"; -import { formatSkillsForPrompt, loadSkills } from "./skills.js"; +import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js"; import type { ToolName } from "./tools/index.js"; /** Tool descriptions for system prompt */ @@ -58,29 +58,39 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } return null; } +export interface LoadContextFilesOptions { + /** Working directory to start walking up from. Default: process.cwd() */ + cwd?: string; + /** Agent config directory for global context. Default: from getAgentDir() */ + agentDir?: string; +} + /** * Load all project context files in order: - * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md + * 1. Global: agentDir/AGENTS.md or CLAUDE.md * 2. Parent directories (top-most first) down to cwd * Each returns {path, content} for separate messages */ -export function loadProjectContextFiles(): Array<{ path: string; content: string }> { +export function loadProjectContextFiles( + options: LoadContextFilesOptions = {}, +): Array<{ path: string; content: string }> { + const resolvedCwd = options.cwd ?? process.cwd(); + const resolvedAgentDir = options.agentDir ?? getAgentDir(); + const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); - // 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/ - const globalContextDir = getAgentDir(); - const globalContext = loadContextFileFromDir(globalContextDir); + // 1. Load global context from agentDir + const globalContext = loadContextFileFromDir(resolvedAgentDir); if (globalContext) { contextFiles.push(globalContext); seenPaths.add(globalContext.path); } // 2. Walk up from cwd to root, collecting all context files - const cwd = process.cwd(); const ancestorContextFiles: Array<{ path: string; content: string }> = []; - let currentDir = cwd; + let currentDir = resolvedCwd; const root = resolve("/"); while (true) { @@ -107,15 +117,37 @@ export function loadProjectContextFiles(): Array<{ path: string; content: string } export interface BuildSystemPromptOptions { + /** Custom system prompt (replaces default). */ customPrompt?: string; + /** Tools to include in prompt. Default: [read, bash, edit, write] */ selectedTools?: ToolName[]; + /** Text to append to system prompt. */ appendSystemPrompt?: string; + /** Skills settings for discovery. */ skillsSettings?: SkillsSettings; + /** Working directory. Default: process.cwd() */ + cwd?: string; + /** Agent config directory. Default: from getAgentDir() */ + agentDir?: string; + /** Pre-loaded context files (skips discovery if provided). */ + contextFiles?: Array<{ path: string; content: string }>; + /** Pre-loaded skills (skips discovery if provided). */ + skills?: Skill[]; } /** Build the system prompt with tools, guidelines, and context */ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { - const { customPrompt, selectedTools, appendSystemPrompt, skillsSettings } = options; + const { + customPrompt, + selectedTools, + appendSystemPrompt, + skillsSettings, + cwd, + agentDir, + contextFiles: providedContextFiles, + skills: providedSkills, + } = options; + const resolvedCwd = cwd ?? process.cwd(); const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt"); const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt"); @@ -133,6 +165,14 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : ""; + // Resolve context files: use provided or discover + const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd, agentDir }); + + // Resolve skills: use provided or discover + const skills = + providedSkills ?? + (skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd, agentDir }).skills : []); + if (resolvedCustomPrompt) { let prompt = resolvedCustomPrompt; @@ -141,7 +181,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin } // Append project context files - const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { prompt += "\n\n# Project Context\n\n"; prompt += "The following project context files have been loaded:\n\n"; @@ -152,14 +191,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Append skills section (only if read tool is available) const customPromptHasRead = !selectedTools || selectedTools.includes("read"); - if (skillsSettings?.enabled !== false && customPromptHasRead) { - const { skills } = loadSkills(skillsSettings ?? {}); + if (customPromptHasRead && skills.length > 0) { prompt += formatSkillsForPrompt(skills); } // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; - prompt += `\nCurrent working directory: ${process.cwd()}`; + prompt += `\nCurrent working directory: ${resolvedCwd}`; return prompt; } @@ -248,7 +286,6 @@ Documentation: } // Append project context files - const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { prompt += "\n\n# Project Context\n\n"; prompt += "The following project context files have been loaded:\n\n"; @@ -258,14 +295,13 @@ Documentation: } // Append skills section (only if read tool is available) - if (skillsSettings?.enabled !== false && hasRead) { - const { skills } = loadSkills(skillsSettings ?? {}); + if (hasRead && skills.length > 0) { prompt += formatSkillsForPrompt(skills); } // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; - prompt += `\nCurrent working directory: ${process.cwd()}`; + prompt += `\nCurrent working directory: ${resolvedCwd}`; return prompt; } diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 034f554a..d9da52ca 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,3 +1,5 @@ +import type { AgentTool } from "@mariozechner/pi-ai"; + export { type BashToolDetails, bashTool } from "./bash.js"; export { editTool } from "./edit.js"; export { type FindToolDetails, findTool } from "./find.js"; @@ -15,8 +17,14 @@ import { lsTool } from "./ls.js"; import { readTool } from "./read.js"; import { writeTool } from "./write.js"; +/** Tool type (AgentTool from pi-ai) */ +export type Tool = AgentTool; + // Default tools for full access mode -export const codingTools = [readTool, bashTool, editTool, writeTool]; +export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool]; + +// Read-only tools for exploration without modification +export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool]; // All available tools (including read-only exploration tools) export const allTools = { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7683c4a9..9fe63515 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -83,6 +83,29 @@ export { type OAuthPrompt, type OAuthProvider, } from "./core/oauth/index.js"; +// SDK for programmatic usage +export { + allBuiltInTools, + type BuildSystemPromptOptions, + buildSystemPrompt, + type CreateAgentSessionOptions, + // Factory + createAgentSession, + // Helpers + defaultGetApiKey, + discoverAvailableModels, + discoverContextFiles, + discoverCustomTools, + discoverHooks, + // Discovery + discoverModels, + discoverSkills, + discoverSlashCommands, + findModel as findModelByProviderAndId, + loadSettings, + // Tools + readOnlyTools, +} from "./core/sdk.js"; export { type CompactionEntry, createSummaryMessage, From bfc1c44791c75959987e0d257298e2bd41c8f8a9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 00:48:47 +0100 Subject: [PATCH 02/25] Use SDK's discoverSlashCommands in main.ts --- packages/coding-agent/src/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 093b7ab4..0e4a833b 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -19,9 +19,9 @@ import { discoverAndLoadHooks, HookRunner, wrapToolsWithHooks } from "./core/hoo import { messageTransformer } from "./core/messages.js"; import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js"; import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js"; +import { discoverSlashCommands } from "./core/sdk.js"; import { SessionManager } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; -import { loadSlashCommands } from "./core/slash-commands.js"; import { buildSystemPrompt } from "./core/system-prompt.js"; import { allTools, codingTools } from "./core/tools/index.js"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; @@ -435,7 +435,7 @@ export async function main(args: string[]) { } // Load file commands for slash command expansion - const fileCommands = loadSlashCommands(); + const fileCommands = discoverSlashCommands(); // Create session const session = new AgentSession({ From 4398596d41b94e9ac44546c17f7894b3625001a4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 00:55:42 +0100 Subject: [PATCH 03/25] Refactor main.ts to use SDK createAgentSession - Add continueSession, restoreFromSession, additionalHookPaths, additionalCustomToolPaths, scopedModels to CreateAgentSessionOptions - Return CreateAgentSessionResult with session, customToolsResult, and modelFallbackMessage - main.ts now focuses on CLI arg parsing and mode routing - Reduced main.ts from ~490 to ~340 lines --- packages/coding-agent/src/core/sdk.ts | 155 ++++-- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 659 ++++++++++---------------- 3 files changed, 367 insertions(+), 448 deletions(-) diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9bae188f..ab1c13ed 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -82,6 +82,8 @@ export interface CreateAgentSessionOptions { model?: Model; /** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */ thinkingLevel?: ThinkingLevel; + /** Models available for cycling (Ctrl+P in interactive mode) */ + scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; // === API Key === /** API key resolver. Default: defaultGetApiKey() */ @@ -94,12 +96,16 @@ export interface CreateAgentSessionOptions { // === Tools === /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ tools?: Tool[]; - /** Custom tools. Default: discovered from cwd/.pi/tools/ + agentDir/tools/ */ + /** Custom tools (replaces discovery). */ customTools?: Array<{ path?: string; tool: CustomAgentTool }>; + /** Additional custom tool paths to load (merged with discovery). */ + additionalCustomToolPaths?: string[]; // === Hooks === - /** Hooks. Default: discovered from cwd/.pi/hooks/ + agentDir/hooks/ */ + /** Hooks (replaces discovery). */ hooks?: Array<{ path?: string; factory: HookFactory }>; + /** Additional hook paths to load (merged with discovery). */ + additionalHookPaths?: string[]; // === Context === /** Skills. Default: discovered from multiple locations */ @@ -112,12 +118,29 @@ export interface CreateAgentSessionOptions { // === Session === /** Session file path, or false to disable persistence. Default: auto in agentDir/sessions/ */ sessionFile?: string | false; + /** Continue most recent session for cwd. */ + continueSession?: boolean; + /** Restore model/thinking from session (default: true when continuing). */ + restoreFromSession?: boolean; // === Settings === /** Settings overrides (merged with agentDir/settings.json) */ settings?: Partial; } +/** Result from createAgentSession */ +export interface CreateAgentSessionResult { + /** The created session */ + session: AgentSession; + /** Custom tools result (for UI context setup in interactive mode) */ + customToolsResult: { + tools: LoadedCustomTool[]; + setUIContext: (uiContext: any, hasUI: boolean) => void; + }; + /** Warning if session was restored with a different model than saved */ + modelFallbackMessage?: string; +} + // ============================================================================ // Re-exports // ============================================================================ @@ -394,16 +417,21 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa * @example * ```typescript * // Minimal - uses defaults - * const session = await createAgentSession(); + * const { session } = await createAgentSession(); * * // With explicit model - * const session = await createAgentSession({ + * const { session } = await createAgentSession({ * model: findModel('anthropic', 'claude-sonnet-4-20250514'), * thinkingLevel: 'high', * }); * + * // Continue previous session + * const { session, modelFallbackMessage } = await createAgentSession({ + * continueSession: true, + * }); + * * // Full control - * const session = await createAgentSession({ + * const { session } = await createAgentSession({ * model: myModel, * getApiKey: async () => process.env.MY_KEY, * systemPrompt: 'You are helpful.', @@ -414,48 +442,90 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa * }); * ``` */ -export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { +export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getDefaultAgentDir(); // === Settings === const settingsManager = new SettingsManager(agentDir); + // === Session Manager === + const sessionManager = new SessionManager(options.continueSession ?? false, undefined); + if (options.sessionFile === false) { + sessionManager.disable(); + } else if (typeof options.sessionFile === "string") { + sessionManager.setSessionFile(options.sessionFile); + } + // === Model Resolution === let model = options.model; + let modelFallbackMessage: string | undefined; + const shouldRestoreFromSession = options.restoreFromSession ?? (options.continueSession || options.sessionFile); + + // If continuing/restoring, try to get model from session first + if (!model && shouldRestoreFromSession) { + const savedModel = sessionManager.loadModel(); + if (savedModel) { + const restoredModel = findModel(savedModel.provider, savedModel.modelId); + if (restoredModel) { + const key = await getApiKeyForModel(restoredModel); + if (key) { + model = restoredModel; + } + } + // If we couldn't restore, we'll fall back below and set fallback message + if (!model) { + modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}`; + } + } + } + + // If still no model, try settings default if (!model) { - // Try settings default const defaultProvider = settingsManager.getDefaultProvider(); const defaultModelId = settingsManager.getDefaultModel(); if (defaultProvider && defaultModelId) { - model = findModel(defaultProvider, defaultModelId) ?? undefined; - // Verify it has an API key - if (model) { - const key = await getApiKeyForModel(model); - if (!key) { - model = undefined; + const settingsModel = findModel(defaultProvider, defaultModelId); + if (settingsModel) { + const key = await getApiKeyForModel(settingsModel); + if (key) { + model = settingsModel; } } } + } - // Fall back to first available - if (!model) { - const available = await discoverAvailableModels(); - if (available.length === 0) { - throw new Error( - "No models available. Set an API key environment variable " + - "(ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) or provide a model explicitly.", - ); - } - model = available[0]; + // Fall back to first available + if (!model) { + const available = await discoverAvailableModels(); + if (available.length === 0) { + throw new Error( + "No models available. Set an API key environment variable " + + "(ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) or provide a model explicitly.", + ); + } + model = available[0]; + if (modelFallbackMessage) { + modelFallbackMessage += `. Using ${model.provider}/${model.id}`; } } // === Thinking Level Resolution === let thinkingLevel = options.thinkingLevel; + + // If continuing/restoring, try to get thinking level from session + if (thinkingLevel === undefined && shouldRestoreFromSession) { + const savedThinking = sessionManager.loadThinkingLevel(); + if (savedThinking) { + thinkingLevel = savedThinking as ThinkingLevel; + } + } + + // Fall back to settings default if (thinkingLevel === undefined) { thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off"; } + // Clamp to model capabilities if (!model.reasoning) { thinkingLevel = "off"; @@ -487,13 +557,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} setUIContext: () => {}, }; } else { - // Discover custom tools - const result = await discoverAndLoadCustomTools( - settingsManager.getCustomToolPaths(), - cwd, - Object.keys(allTools), - agentDir, - ); + // Discover custom tools, merging with additional paths + const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])]; + const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir); for (const { path, error } of result.errors) { console.error(`Failed to load custom tool "${path}": ${error}`); } @@ -508,8 +574,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout()); } } else { - // Discover hooks - const { hooks, errors } = await discoverAndLoadHooks(settingsManager.getHookPaths(), cwd, agentDir); + // Discover hooks, merging with additional paths + const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])]; + const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir); for (const { path, error } of errors) { console.error(`Failed to load hook "${path}": ${error}`); } @@ -544,15 +611,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // === Slash Commands === const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir); - // === Session Manager === - const sessionManager = new SessionManager(false, undefined); - if (options.sessionFile === false) { - sessionManager.disable(); - } else if (typeof options.sessionFile === "string") { - sessionManager.setSessionFile(options.sessionFile); - } - // If undefined, SessionManager uses auto-detection based on cwd - // === Create Agent === const agent = new Agent({ initialState: { @@ -578,16 +636,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }), }); + // === Load messages if continuing session === + if (shouldRestoreFromSession) { + const messages = sessionManager.loadMessages(); + if (messages.length > 0) { + agent.replaceMessages(messages); + } + } + // === Create Session === const session = new AgentSession({ agent, sessionManager, settingsManager, + scopedModels: options.scopedModels, fileCommands: slashCommands, hookRunner, customTools: customToolsResult.tools, skillsSettings: settingsManager.getSkillsSettings(), }); - return session; + return { + session, + customToolsResult, + modelFallbackMessage, + }; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 9fe63515..87e30209 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -89,6 +89,7 @@ export { type BuildSystemPromptOptions, buildSystemPrompt, type CreateAgentSessionOptions, + type CreateAgentSessionResult, // Factory createAgentSession, // Helpers diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0e4a833b..c072d143 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -1,8 +1,11 @@ /** - * Main entry point for the coding agent + * Main entry point for the coding agent CLI. + * + * This file handles CLI argument parsing and translates them into + * createAgentSession() options. The SDK does the heavy lifting. */ -import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; import { setOAuthStorage, supportsXhigh } from "@mariozechner/pi-ai"; import chalk from "chalk"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; @@ -12,17 +15,12 @@ import { processFileArguments } from "./cli/file-processor.js"; import { listModels } from "./cli/list-models.js"; import { selectSession } from "./cli/session-picker.js"; import { getModelsPath, getOAuthPath, VERSION } from "./config.js"; -import { AgentSession } from "./core/agent-session.js"; -import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./core/custom-tools/index.js"; +import type { AgentSession } from "./core/agent-session.js"; +import type { LoadedCustomTool } from "./core/custom-tools/index.js"; import { exportFromFile } from "./core/export-html.js"; -import { discoverAndLoadHooks, HookRunner, wrapToolsWithHooks } from "./core/hooks/index.js"; -import { messageTransformer } from "./core/messages.js"; -import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js"; -import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js"; -import { discoverSlashCommands } from "./core/sdk.js"; -import { SessionManager } from "./core/session-manager.js"; +import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; +import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js"; import { SettingsManager } from "./core/settings-manager.js"; -import { buildSystemPrompt } from "./core/system-prompt.js"; import { allTools, codingTools } from "./core/tools/index.js"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"; @@ -70,7 +68,6 @@ async function checkForNewVersion(currentVersion: string): Promise, initialMessages: string[], customTools: LoadedCustomTool[], @@ -91,25 +88,20 @@ async function runInteractiveMode( ): Promise { const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath); - // Initialize TUI (subscribes to agent events internally) await mode.init(); - // Handle version check result when it completes (don't block) versionCheckPromise.then((newVersion) => { if (newVersion) { mode.showNewVersionNotification(newVersion); } }); - // Render any existing messages (from --continue mode) mode.renderInitialMessages(session.state); - // Show model fallback warning at the end of the chat if applicable if (modelFallbackMessage) { mode.showWarning(modelFallbackMessage); } - // Process initial message with attachments if provided (from @file args) if (initialMessage) { try { await session.prompt(initialMessage, { attachments: initialAttachments }); @@ -119,7 +111,6 @@ async function runInteractiveMode( } } - // Process remaining initial messages if provided (from CLI args) for (const message of initialMessages) { try { await session.prompt(message); @@ -129,11 +120,8 @@ async function runInteractiveMode( } } - // Interactive loop while (true) { const userInput = await mode.getUserInput(); - - // Process the message try { await session.prompt(userInput); } catch (error: unknown) { @@ -154,11 +142,10 @@ async function prepareInitialMessage(parsed: Args): Promise<{ const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs); - // Combine file content with first plain text message (if any) let initialMessage: string; if (parsed.messages.length > 0) { initialMessage = textContent + parsed.messages[0]; - parsed.messages.shift(); // Remove first message as it's been combined + parsed.messages.shift(); } else { initialMessage = textContent; } @@ -169,386 +156,6 @@ async function prepareInitialMessage(parsed: Args): Promise<{ }; } -export async function main(args: string[]) { - // Configure OAuth storage to use the coding-agent's configurable path - // This must happen before any OAuth operations - configureOAuthStorage(); - - const parsed = parseArgs(args); - - if (parsed.version) { - console.log(VERSION); - return; - } - - if (parsed.help) { - printHelp(); - return; - } - - // Handle --list-models flag: list available models and exit - if (parsed.listModels !== undefined) { - const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; - await listModels(searchPattern); - return; - } - - // Handle --export flag: convert session file to HTML and exit - if (parsed.export) { - try { - const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined; - const result = exportFromFile(parsed.export, outputPath); - console.log(`Exported to: ${result}`); - return; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Failed to export session"; - console.error(chalk.red(`Error: ${message}`)); - process.exit(1); - } - } - - // Validate: RPC mode doesn't support @file arguments - if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { - console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); - process.exit(1); - } - - // Process @file arguments - const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed); - - // Determine if we're in interactive mode (needed for theme watcher) - const isInteractive = !parsed.print && parsed.mode === undefined; - - // Initialize theme (before any TUI rendering) - const settingsManager = new SettingsManager(); - const themeName = settingsManager.getTheme(); - initTheme(themeName, isInteractive); - - // Setup session manager - const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session); - - if (parsed.noSession) { - sessionManager.disable(); - } - - // Handle --resume flag: show session selector - if (parsed.resume) { - const selectedSession = await selectSession(sessionManager); - if (!selectedSession) { - console.log(chalk.dim("No session selected")); - return; - } - sessionManager.setSessionFile(selectedSession); - } - - // Resolve model scope early if provided - let scopedModels: ScopedModel[] = []; - if (parsed.models && parsed.models.length > 0) { - scopedModels = await resolveModelScope(parsed.models); - } - - // Determine mode and output behavior - const mode = parsed.mode || "text"; - const shouldPrintMessages = isInteractive; - - // Find initial model - let initialModel = await findInitialModelForSession(parsed, scopedModels, settingsManager); - let initialThinking: ThinkingLevel = "off"; - - // Get thinking level from scoped models if applicable - if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { - initialThinking = scopedModels[0].thinkingLevel; - } else { - // Try saved thinking level - const savedThinking = settingsManager.getDefaultThinkingLevel(); - if (savedThinking) { - initialThinking = savedThinking; - } - } - - // Non-interactive mode: fail early if no model available - if (!isInteractive && !initialModel) { - console.error(chalk.red("No models available.")); - console.error(chalk.yellow("\nSet an API key environment variable:")); - console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); - console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); - process.exit(1); - } - - // Non-interactive mode: validate API key exists - if (!isInteractive && initialModel) { - const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel)); - if (!apiKey) { - console.error(chalk.red(`No API key found for ${initialModel.provider}`)); - process.exit(1); - } - } - - // Build system prompt - const skillsSettings = settingsManager.getSkillsSettings(); - if (parsed.noSkills) { - skillsSettings.enabled = false; - } - if (parsed.skills && parsed.skills.length > 0) { - skillsSettings.includeSkills = parsed.skills; - } - const systemPrompt = buildSystemPrompt({ - customPrompt: parsed.systemPrompt, - selectedTools: parsed.tools, - appendSystemPrompt: parsed.appendSystemPrompt, - skillsSettings, - }); - - // Handle session restoration - let modelFallbackMessage: string | null = null; - - if (parsed.continue || parsed.resume || parsed.session) { - const savedModel = sessionManager.loadModel(); - if (savedModel) { - const result = await restoreModelFromSession( - savedModel.provider, - savedModel.modelId, - initialModel, - shouldPrintMessages, - ); - - if (result.model) { - initialModel = result.model; - } - modelFallbackMessage = result.fallbackMessage; - } - - // Load and restore thinking level - const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel; - if (thinkingLevel) { - initialThinking = thinkingLevel; - if (shouldPrintMessages) { - console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`)); - } - } - } - - // CLI --thinking flag takes highest priority - if (parsed.thinking) { - initialThinking = parsed.thinking; - } - - // Clamp thinking level to model capabilities - if (initialModel) { - if (!initialModel.reasoning) { - initialThinking = "off"; - } else if (initialThinking === "xhigh" && !supportsXhigh(initialModel)) { - initialThinking = "high"; - } - } - - // Determine which tools to use - let selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools; - - // Discover and load hooks from: - // 1. ~/.pi/agent/hooks/*.ts (global) - // 2. cwd/.pi/hooks/*.ts (project-local) - // 3. Explicit paths in settings.json - // 4. CLI --hook flags - let hookRunner: HookRunner | null = null; - const cwd = process.cwd(); - const configuredHookPaths = [...settingsManager.getHookPaths(), ...(parsed.hooks ?? [])]; - const { hooks, errors } = await discoverAndLoadHooks(configuredHookPaths, cwd); - - // Report hook loading errors - for (const { path, error } of errors) { - console.error(chalk.red(`Failed to load hook "${path}": ${error}`)); - } - - if (hooks.length > 0) { - const timeout = settingsManager.getHookTimeout(); - hookRunner = new HookRunner(hooks, cwd, timeout); - } - - // Discover and load custom tools from: - // 1. ~/.pi/agent/tools/*.ts (global) - // 2. cwd/.pi/tools/*.ts (project-local) - // 3. Explicit paths in settings.json - // 4. CLI --tool flags - const configuredToolPaths = [...settingsManager.getCustomToolPaths(), ...(parsed.customTools ?? [])]; - const builtInToolNames = Object.keys(allTools); - const { - tools: loadedCustomTools, - errors: toolErrors, - setUIContext: setToolUIContext, - } = await discoverAndLoadCustomTools(configuredToolPaths, cwd, builtInToolNames); - - // Report custom tool loading errors - for (const { path, error } of toolErrors) { - console.error(chalk.red(`Failed to load custom tool "${path}": ${error}`)); - } - - // Add custom tools to selected tools - if (loadedCustomTools.length > 0) { - const customToolInstances = loadedCustomTools.map((lt) => lt.tool); - selectedTools = [...selectedTools, ...customToolInstances] as typeof selectedTools; - } - - // Wrap tools with hook callbacks (built-in and custom) - if (hookRunner) { - selectedTools = wrapToolsWithHooks(selectedTools, hookRunner); - } - - // Create agent - const agent = new Agent({ - initialState: { - systemPrompt, - model: initialModel as any, // Can be null in interactive mode - thinkingLevel: initialThinking, - tools: selectedTools, - }, - messageTransformer, - queueMode: settingsManager.getQueueMode(), - transport: new ProviderTransport({ - getApiKey: async () => { - const currentModel = agent.state.model; - if (!currentModel) { - throw new Error("No model selected"); - } - - if (parsed.apiKey) { - return parsed.apiKey; - } - - const key = await getApiKeyForModel(currentModel); - if (!key) { - throw new Error( - `No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ${getModelsPath()}`, - ); - } - return key; - }, - }), - }); - - // Load previous messages if continuing, resuming, or using --session - if (parsed.continue || parsed.resume || parsed.session) { - const messages = sessionManager.loadMessages(); - if (messages.length > 0) { - agent.replaceMessages(messages); - } - } - - // Load file commands for slash command expansion - const fileCommands = discoverSlashCommands(); - - // Create session - const session = new AgentSession({ - agent, - sessionManager, - settingsManager, - scopedModels, - fileCommands, - hookRunner, - customTools: loadedCustomTools, - skillsSettings, - }); - - // Route to appropriate mode - if (mode === "rpc") { - await runRpcMode(session); - } else if (isInteractive) { - // Check for new version in the background - const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null); - - // Check if we should show changelog - const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager); - - // Show model scope if provided - if (scopedModels.length > 0) { - const modelList = scopedModels - .map((sm) => { - const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : ""; - return `${sm.model.id}${thinkingStr}`; - }) - .join(", "); - console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); - } - - // Ensure fd tool is available for file autocomplete - const fdPath = await ensureTool("fd"); - - await runInteractiveMode( - session, - VERSION, - changelogMarkdown, - modelFallbackMessage, - versionCheckPromise, - parsed.messages, - loadedCustomTools, - setToolUIContext, - initialMessage, - initialAttachments, - fdPath, - ); - } else { - // Non-interactive mode (--print flag or --mode flag) - await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments); - // Clean up and exit (file watchers keep process alive) - stopThemeWatcher(); - // Wait for stdout to fully flush before exiting - if (process.stdout.writableLength > 0) { - await new Promise((resolve) => process.stdout.once("drain", resolve)); - } - process.exit(0); - } -} - -/** Find initial model based on CLI args, scoped models, settings, or available models */ -async function findInitialModelForSession(parsed: Args, scopedModels: ScopedModel[], settingsManager: SettingsManager) { - // 1. CLI args take priority - if (parsed.provider && parsed.model) { - const { model, error } = findModel(parsed.provider, parsed.model); - if (error) { - console.error(chalk.red(error)); - process.exit(1); - } - if (!model) { - console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`)); - process.exit(1); - } - return model; - } - - // 2. Use first model from scoped models (skip if continuing/resuming) - if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { - return scopedModels[0].model; - } - - // 3. Try saved default from settings - const defaultProvider = settingsManager.getDefaultProvider(); - const defaultModelId = settingsManager.getDefaultModel(); - if (defaultProvider && defaultModelId) { - const { model, error } = findModel(defaultProvider, defaultModelId); - if (error) { - console.error(chalk.red(error)); - process.exit(1); - } - if (model) { - return model; - } - } - - // 4. Try first available model with valid API key - const { models: availableModels, error } = await getAvailableModels(); - - if (error) { - console.error(chalk.red(error)); - process.exit(1); - } - - if (availableModels.length > 0) { - return availableModels[0]; - } - - return null; -} - /** Get changelog markdown to display (only for new sessions with updates) */ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null { if (parsed.continue || parsed.resume) { @@ -560,13 +167,11 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): const entries = parseChangelog(changelogPath); if (!lastVersion) { - // First run - show all entries if (entries.length > 0) { settingsManager.setLastChangelogVersion(VERSION); return entries.map((e) => e.content).join("\n\n"); } } else { - // Check for new entries since last version const newEntries = getNewEntries(entries, lastVersion); if (newEntries.length > 0) { settingsManager.setLastChangelogVersion(VERSION); @@ -576,3 +181,245 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): return null; } + +/** Build CreateAgentSessionOptions from CLI args */ +async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): Promise { + const options: CreateAgentSessionOptions = {}; + + // Model from CLI + if (parsed.provider && parsed.model) { + // Will be resolved by SDK + // For now, we need to find it ourselves since SDK expects Model object + const { findModel } = await import("./core/model-config.js"); + const { model, error } = findModel(parsed.provider, parsed.model); + if (error) { + console.error(chalk.red(error)); + process.exit(1); + } + if (!model) { + console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`)); + process.exit(1); + } + options.model = model; + } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { + // Use first scoped model + options.model = scopedModels[0].model; + } + + // Thinking level + if (parsed.thinking) { + options.thinkingLevel = parsed.thinking; + } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { + options.thinkingLevel = scopedModels[0].thinkingLevel; + } + + // Scoped models for Ctrl+P cycling + if (scopedModels.length > 0) { + options.scopedModels = scopedModels; + } + + // API key from CLI + if (parsed.apiKey) { + options.getApiKey = async () => parsed.apiKey!; + } + + // System prompt + if (parsed.systemPrompt && parsed.appendSystemPrompt) { + options.systemPrompt = (defaultPrompt) => { + // Custom prompt with append + return parsed.systemPrompt + "\n\n" + parsed.appendSystemPrompt; + }; + } else if (parsed.systemPrompt) { + options.systemPrompt = parsed.systemPrompt; + } else if (parsed.appendSystemPrompt) { + options.systemPrompt = (defaultPrompt) => defaultPrompt + "\n\n" + parsed.appendSystemPrompt; + } + + // Tools + if (parsed.tools) { + options.tools = parsed.tools.map((name) => allTools[name]); + } + + // Skills + if (parsed.noSkills) { + options.skills = []; + } + + // Additional hook paths from CLI + if (parsed.hooks && parsed.hooks.length > 0) { + options.additionalHookPaths = parsed.hooks; + } + + // Additional custom tool paths from CLI + if (parsed.customTools && parsed.customTools.length > 0) { + options.additionalCustomToolPaths = parsed.customTools; + } + + // Session handling + if (parsed.noSession) { + options.sessionFile = false; + } else if (parsed.session) { + options.sessionFile = parsed.session; + } + + // Continue session + if (parsed.continue && !parsed.resume) { + options.continueSession = true; + } + + return options; +} + +export async function main(args: string[]) { + // Configure OAuth storage first + configureOAuthStorage(); + + const parsed = parseArgs(args); + + // === Early exits === + + if (parsed.version) { + console.log(VERSION); + return; + } + + if (parsed.help) { + printHelp(); + return; + } + + if (parsed.listModels !== undefined) { + const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; + await listModels(searchPattern); + return; + } + + if (parsed.export) { + try { + const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined; + const result = exportFromFile(parsed.export, outputPath); + console.log(`Exported to: ${result}`); + return; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to export session"; + console.error(chalk.red(`Error: ${message}`)); + process.exit(1); + } + } + + // === Validation === + + if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { + console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); + process.exit(1); + } + + // === Prepare inputs === + + const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed); + const isInteractive = !parsed.print && parsed.mode === undefined; + const mode = parsed.mode || "text"; + + // Initialize theme early + const settingsManager = new SettingsManager(); + initTheme(settingsManager.getTheme(), isInteractive); + + // Resolve scoped models from --models flag + let scopedModels: ScopedModel[] = []; + if (parsed.models && parsed.models.length > 0) { + scopedModels = await resolveModelScope(parsed.models); + } + + // Handle --resume: show session picker + let sessionFileFromResume: string | undefined; + if (parsed.resume) { + const { SessionManager } = await import("./core/session-manager.js"); + const tempSessionManager = new SessionManager(false, undefined); + const selectedSession = await selectSession(tempSessionManager); + if (!selectedSession) { + console.log(chalk.dim("No session selected")); + return; + } + sessionFileFromResume = selectedSession; + } + + // === Build session options === + + const sessionOptions = await buildSessionOptions(parsed, scopedModels); + + // Apply resume session file + if (sessionFileFromResume) { + sessionOptions.sessionFile = sessionFileFromResume; + sessionOptions.restoreFromSession = true; + } + + // === Create session === + + const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions); + + // === Validate for non-interactive mode === + + if (!isInteractive && !session.model) { + console.error(chalk.red("No models available.")); + console.error(chalk.yellow("\nSet an API key environment variable:")); + console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); + console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); + process.exit(1); + } + + // Clamp thinking level to model capabilities (for CLI override case) + if (session.model && parsed.thinking) { + let effectiveThinking = parsed.thinking; + if (!session.model.reasoning) { + effectiveThinking = "off"; + } else if (effectiveThinking === "xhigh" && !supportsXhigh(session.model)) { + effectiveThinking = "high"; + } + if (effectiveThinking !== session.thinkingLevel) { + session.setThinkingLevel(effectiveThinking); + } + } + + // === Route to mode === + + if (mode === "rpc") { + await runRpcMode(session); + } else if (isInteractive) { + const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null); + const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager); + + // Show model scope if provided + if (scopedModels.length > 0) { + const modelList = scopedModels + .map((sm) => { + const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : ""; + return `${sm.model.id}${thinkingStr}`; + }) + .join(", "); + console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); + } + + const fdPath = await ensureTool("fd"); + + await runInteractiveMode( + session, + VERSION, + changelogMarkdown, + modelFallbackMessage, + versionCheckPromise, + parsed.messages, + customToolsResult.tools, + customToolsResult.setUIContext, + initialMessage, + initialAttachments, + fdPath, + ); + } else { + await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments); + stopThemeWatcher(); + if (process.stdout.writableLength > 0) { + await new Promise((resolve) => process.stdout.once("drain", resolve)); + } + process.exit(0); + } +} From 67a5fbde15e5945c36d8b4de472494cf6f747e8b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 00:58:36 +0100 Subject: [PATCH 04/25] Remove inline imports from main.ts --- packages/coding-agent/src/main.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c072d143..71fa6d14 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,7 +5,7 @@ * createAgentSession() options. The SDK does the heavy lifting. */ -import type { Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Attachment } from "@mariozechner/pi-agent-core"; import { setOAuthStorage, supportsXhigh } from "@mariozechner/pi-ai"; import chalk from "chalk"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; @@ -18,10 +18,12 @@ import { getModelsPath, getOAuthPath, VERSION } from "./config.js"; import type { AgentSession } from "./core/agent-session.js"; import type { LoadedCustomTool } from "./core/custom-tools/index.js"; import { exportFromFile } from "./core/export-html.js"; +import { findModel } from "./core/model-config.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js"; +import { SessionManager } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; -import { allTools, codingTools } from "./core/tools/index.js"; +import { allTools } from "./core/tools/index.js"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js"; @@ -188,9 +190,6 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P // Model from CLI if (parsed.provider && parsed.model) { - // Will be resolved by SDK - // For now, we need to find it ourselves since SDK expects Model object - const { findModel } = await import("./core/model-config.js"); const { model, error } = findModel(parsed.provider, parsed.model); if (error) { console.error(chalk.red(error)); @@ -333,7 +332,6 @@ export async function main(args: string[]) { // Handle --resume: show session picker let sessionFileFromResume: string | undefined; if (parsed.resume) { - const { SessionManager } = await import("./core/session-manager.js"); const tempSessionManager = new SessionManager(false, undefined); const selectedSession = await selectSession(tempSessionManager); if (!selectedSession) { From 6201bae8057023143a719de1a8e399660a49718c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 00:58:53 +0100 Subject: [PATCH 05/25] Fix lint warnings in main.ts --- packages/coding-agent/src/main.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 71fa6d14..3c1e2a68 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -224,14 +224,11 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P // System prompt if (parsed.systemPrompt && parsed.appendSystemPrompt) { - options.systemPrompt = (defaultPrompt) => { - // Custom prompt with append - return parsed.systemPrompt + "\n\n" + parsed.appendSystemPrompt; - }; + options.systemPrompt = `${parsed.systemPrompt}\n\n${parsed.appendSystemPrompt}`; } else if (parsed.systemPrompt) { options.systemPrompt = parsed.systemPrompt; } else if (parsed.appendSystemPrompt) { - options.systemPrompt = (defaultPrompt) => defaultPrompt + "\n\n" + parsed.appendSystemPrompt; + options.systemPrompt = (defaultPrompt) => `${defaultPrompt}\n\n${parsed.appendSystemPrompt}`; } // Tools From 7bf4c8ff24a06643010ad824a04a92ce4a807bf4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 01:02:42 +0100 Subject: [PATCH 06/25] Remove unnecessary section divider comments --- packages/coding-agent/src/core/sdk.ts | 43 --------------------------- packages/coding-agent/src/main.ts | 14 --------- 2 files changed, 57 deletions(-) diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index ab1c13ed..1dd44834 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -66,18 +66,14 @@ import { writeTool, } from "./tools/index.js"; -// ============================================================================ // Types -// ============================================================================ export interface CreateAgentSessionOptions { - // === Environment === /** Working directory for project-local discovery. Default: process.cwd() */ cwd?: string; /** Global config directory. Default: ~/.pi/agent */ agentDir?: string; - // === Model & Thinking === /** Model to use. Default: from settings, else first available */ model?: Model; /** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */ @@ -85,15 +81,12 @@ export interface CreateAgentSessionOptions { /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; - // === API Key === /** API key resolver. Default: defaultGetApiKey() */ getApiKey?: (model: Model) => Promise; - // === System Prompt === /** System prompt. String replaces default, function receives default and returns final. */ systemPrompt?: string | ((defaultPrompt: string) => string); - // === Tools === /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ tools?: Tool[]; /** Custom tools (replaces discovery). */ @@ -101,13 +94,11 @@ export interface CreateAgentSessionOptions { /** Additional custom tool paths to load (merged with discovery). */ additionalCustomToolPaths?: string[]; - // === Hooks === /** Hooks (replaces discovery). */ hooks?: Array<{ path?: string; factory: HookFactory }>; /** Additional hook paths to load (merged with discovery). */ additionalHookPaths?: string[]; - // === Context === /** Skills. Default: discovered from multiple locations */ skills?: Skill[]; /** Context files (AGENTS.md content). Default: discovered walking up from cwd */ @@ -115,7 +106,6 @@ export interface CreateAgentSessionOptions { /** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */ slashCommands?: FileSlashCommand[]; - // === Session === /** Session file path, or false to disable persistence. Default: auto in agentDir/sessions/ */ sessionFile?: string | false; /** Continue most recent session for cwd. */ @@ -123,7 +113,6 @@ export interface CreateAgentSessionOptions { /** Restore model/thinking from session (default: true when continuing). */ restoreFromSession?: boolean; - // === Settings === /** Settings overrides (merged with agentDir/settings.json) */ settings?: Partial; } @@ -141,9 +130,7 @@ export interface CreateAgentSessionResult { modelFallbackMessage?: string; } -// ============================================================================ // Re-exports -// ============================================================================ export type { CustomAgentTool } from "./custom-tools/types.js"; export type { HookAPI, HookFactory } from "./hooks/types.js"; @@ -165,17 +152,13 @@ export { allTools as allBuiltInTools, }; -// ============================================================================ // Helper Functions -// ============================================================================ function getDefaultAgentDir(): string { return getAgentDir(); } -// ============================================================================ // Discovery Functions -// ============================================================================ /** * Get all models (built-in + custom from models.json). @@ -292,9 +275,7 @@ export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlas }); } -// ============================================================================ // API Key Helpers -// ============================================================================ /** * Create the default API key resolver. @@ -305,9 +286,7 @@ export function defaultGetApiKey(): (model: Model) => Promise void }; if (options.customTools !== undefined) { // Use provided custom tools @@ -566,7 +530,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} customToolsResult = result; } - // === Hooks === let hookRunner: HookRunner | null = null; if (options.hooks !== undefined) { if (options.hooks.length > 0) { @@ -585,13 +548,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } - // === Combine and wrap tools === let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)]; if (hookRunner) { allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; } - // === System Prompt === let systemPrompt: string; const defaultPrompt = buildSystemPromptInternal({ cwd, @@ -608,10 +569,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} systemPrompt = options.systemPrompt(defaultPrompt); } - // === Slash Commands === const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir); - // === Create Agent === const agent = new Agent({ initialState: { systemPrompt, @@ -636,7 +595,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }), }); - // === Load messages if continuing session === if (shouldRestoreFromSession) { const messages = sessionManager.loadMessages(); if (messages.length > 0) { @@ -644,7 +602,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } - // === Create Session === const session = new AgentSession({ agent, sessionManager, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 3c1e2a68..b02ae8a2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -272,8 +272,6 @@ export async function main(args: string[]) { const parsed = parseArgs(args); - // === Early exits === - if (parsed.version) { console.log(VERSION); return; @@ -303,15 +301,11 @@ export async function main(args: string[]) { } } - // === Validation === - if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); } - // === Prepare inputs === - const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed); const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; @@ -338,8 +332,6 @@ export async function main(args: string[]) { sessionFileFromResume = selectedSession; } - // === Build session options === - const sessionOptions = await buildSessionOptions(parsed, scopedModels); // Apply resume session file @@ -348,12 +340,8 @@ export async function main(args: string[]) { sessionOptions.restoreFromSession = true; } - // === Create session === - const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions); - // === Validate for non-interactive mode === - if (!isInteractive && !session.model) { console.error(chalk.red("No models available.")); console.error(chalk.yellow("\nSet an API key environment variable:")); @@ -375,8 +363,6 @@ export async function main(args: string[]) { } } - // === Route to mode === - if (mode === "rpc") { await runRpcMode(session); } else if (isInteractive) { From ace8ea3d5bec546093eeb03d6db00b12d225d491 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 01:29:54 +0100 Subject: [PATCH 07/25] Refactor SessionManager to use static factory methods - Add factory methods: create(cwd), open(path), continueRecent(cwd), inMemory() - Add static list(cwd) for session listing - Make constructor private, pass cwd explicitly - Update SDK to take sessionManager instead of sessionFile options - Update main.ts to create SessionManager based on CLI flags - Update SessionSelectorComponent to take sessions[] instead of SessionManager - Update tests to use factory methods --- .../coding-agent/src/cli/session-picker.ts | 6 +- packages/coding-agent/src/core/sdk.ts | 62 +- .../coding-agent/src/core/session-manager.ts | 631 ++++++++---------- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 74 +- .../components/session-selector.ts | 23 +- .../src/modes/interactive/interactive-mode.ts | 10 +- .../test/agent-session-branching.test.ts | 5 +- .../test/agent-session-compaction.test.ts | 7 +- 9 files changed, 346 insertions(+), 473 deletions(-) diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index bcb921b2..f1ff837b 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -3,17 +3,17 @@ */ import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; -import type { SessionManager } from "../core/session-manager.js"; +import type { SessionInfo } from "../core/session-manager.js"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; /** Show TUI session selector and return selected session path or null if cancelled */ -export async function selectSession(sessionManager: SessionManager): Promise { +export async function selectSession(sessions: SessionInfo[]): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); let resolved = false; const selector = new SessionSelectorComponent( - sessionManager, + sessions, (path: string) => { if (!resolved) { resolved = true; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 1dd44834..9b2f3220 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -106,12 +106,8 @@ export interface CreateAgentSessionOptions { /** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */ slashCommands?: FileSlashCommand[]; - /** Session file path, or false to disable persistence. Default: auto in agentDir/sessions/ */ - sessionFile?: string | false; - /** Continue most recent session for cwd. */ - continueSession?: boolean; - /** Restore model/thinking from session (default: true when continuing). */ - restoreFromSession?: boolean; + /** Session manager. Default: SessionManager.create(cwd) */ + sessionManager?: SessionManager; /** Settings overrides (merged with agentDir/settings.json) */ settings?: Partial; @@ -411,7 +407,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa * tools: [readTool, bashTool], * hooks: [], * skills: [], - * sessionFile: false, + * sessionManager: SessionManager.inMemory(), * }); * ``` */ @@ -420,34 +416,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const agentDir = options.agentDir ?? getDefaultAgentDir(); const settingsManager = new SettingsManager(agentDir); + const sessionManager = options.sessionManager ?? SessionManager.create(cwd); - const sessionManager = new SessionManager(options.continueSession ?? false, undefined); - if (options.sessionFile === false) { - sessionManager.disable(); - } else if (typeof options.sessionFile === "string") { - sessionManager.setSessionFile(options.sessionFile); - } + // Check if session has existing data to restore + const existingSession = sessionManager.loadSession(); + const hasExistingSession = existingSession.messages.length > 0; let model = options.model; let modelFallbackMessage: string | undefined; - const shouldRestoreFromSession = options.restoreFromSession ?? (options.continueSession || options.sessionFile); - // If continuing/restoring, try to get model from session first - if (!model && shouldRestoreFromSession) { - const savedModel = sessionManager.loadModel(); - if (savedModel) { - const restoredModel = findModel(savedModel.provider, savedModel.modelId); - if (restoredModel) { - const key = await getApiKeyForModel(restoredModel); - if (key) { - model = restoredModel; - } - } - // If we couldn't restore, we'll fall back below and set fallback message - if (!model) { - modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}`; + // If session has data, try to restore model from it + if (!model && hasExistingSession && existingSession.model) { + const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId); + if (restoredModel) { + const key = await getApiKeyForModel(restoredModel); + if (key) { + model = restoredModel; } } + if (!model) { + modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; + } } // If still no model, try settings default @@ -482,12 +471,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} let thinkingLevel = options.thinkingLevel; - // If continuing/restoring, try to get thinking level from session - if (thinkingLevel === undefined && shouldRestoreFromSession) { - const savedThinking = sessionManager.loadThinkingLevel(); - if (savedThinking) { - thinkingLevel = savedThinking as ThinkingLevel; - } + // If session has data, restore thinking level from it + if (thinkingLevel === undefined && hasExistingSession) { + thinkingLevel = existingSession.thinkingLevel as ThinkingLevel; } // Fall back to settings default @@ -595,11 +581,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }), }); - if (shouldRestoreFromSession) { - const messages = sessionManager.loadMessages(); - if (messages.length > 0) { - agent.replaceMessages(messages); - } + // Restore messages if session has existing data + if (hasExistingSession) { + agent.replaceMessages(existingSession.messages); } const session = new AgentSession({ diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 81050b46..dbce7623 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -12,10 +12,6 @@ function uuidv4(): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } -// ============================================================================ -// Session entry types -// ============================================================================ - export interface SessionHeader { type: "session"; id: string; @@ -50,11 +46,10 @@ export interface CompactionEntry { type: "compaction"; timestamp: string; summary: string; - firstKeptEntryIndex: number; // Index into session entries where we start keeping + firstKeptEntryIndex: number; tokensBefore: number; } -/** Union of all session entry types */ export type SessionEntry = | SessionHeader | SessionMessageEntry @@ -62,16 +57,22 @@ export type SessionEntry = | ModelChangeEntry | CompactionEntry; -// ============================================================================ -// Session loading with compaction support -// ============================================================================ - export interface LoadedSession { messages: AppMessage[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; } +export interface SessionInfo { + path: string; + id: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; + allMessagesText: string; +} + export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: @@ -80,9 +81,6 @@ export const SUMMARY_PREFIX = `The conversation history before this point was co export const SUMMARY_SUFFIX = ` `; -/** - * Create a user message containing the summary with the standard prefix. - */ export function createSummaryMessage(summary: string): AppMessage { return { role: "user", @@ -91,9 +89,6 @@ export function createSummaryMessage(summary: string): AppMessage { }; } -/** - * Parse session file content into entries. - */ export function parseSessionEntries(content: string): SessionEntry[] { const entries: SessionEntry[] = []; const lines = content.trim().split("\n"); @@ -111,17 +106,6 @@ export function parseSessionEntries(content: string): SessionEntry[] { return entries; } -/** - * Load session from entries, handling compaction events. - * - * Algorithm: - * 1. Find latest compaction event (if any) - * 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages) - * 3. Prepend summary as user message - */ -/** - * Get the latest compaction entry from session entries, if any. - */ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null { for (let i = entries.length - 1; i >= 0; i--) { if (entries[i].type === "compaction") { @@ -132,7 +116,6 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt } export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { - // Find model and thinking level (always scan all entries) let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; @@ -147,7 +130,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { } } - // Find latest compaction event let latestCompactionIndex = -1; for (let i = entries.length - 1; i >= 0; i--) { if (entries[i].type === "compaction") { @@ -156,7 +138,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { } } - // No compaction: return all messages if (latestCompactionIndex === -1) { const messages: AppMessage[] = []; for (const entry of entries) { @@ -169,7 +150,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { const compactionEvent = entries[latestCompactionIndex] as CompactionEntry; - // Extract messages from firstKeptEntryIndex to end (skipping compaction entries) const keptMessages: AppMessage[] = []; for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) { const entry = entries[i]; @@ -178,7 +158,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { } } - // Build final messages: summary + kept messages const messages: AppMessage[] = []; messages.push(createSummaryMessage(compactionEvent.summary)); messages.push(...keptMessages); @@ -186,320 +165,137 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { return { messages, thinkingLevel, model }; } +function getSessionDirectory(cwd: string): string { + const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const configDir = getAgentDir(); + const sessionDir = join(configDir, "sessions", safePath); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + return sessionDir; +} + +function loadEntriesFromFile(filePath: string): SessionEntry[] { + if (!existsSync(filePath)) return []; + + const content = readFileSync(filePath, "utf8"); + const entries: SessionEntry[] = []; + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as SessionEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + + return entries; +} + +function extractSessionIdFromFile(filePath: string): string | null { + if (!existsSync(filePath)) return null; + + const lines = readFileSync(filePath, "utf8").trim().split("\n"); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "session") { + return entry.id; + } + } catch { + // Skip malformed lines + } + } + return null; +} + +function findMostRecentSession(sessionDir: string): string | null { + try { + const files = readdirSync(sessionDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => ({ + path: join(sessionDir, f), + mtime: statSync(join(sessionDir, f)).mtime, + })) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + return files[0]?.path || null; + } catch { + return null; + } +} + export class SessionManager { - private sessionId!: string; - private sessionFile!: string; + private sessionId: string; + private sessionFile: string; private sessionDir: string; - private enabled: boolean = true; - private sessionInitialized: boolean = false; + private cwd: string; + private enabled: boolean; + private sessionInitialized: boolean; private pendingEntries: SessionEntry[] = []; - // In-memory entries for --no-session mode (when enabled=false) private inMemoryEntries: SessionEntry[] = []; - constructor(continueSession: boolean = false, customSessionPath?: string) { - this.sessionDir = this.getSessionDirectory(); + private constructor(cwd: string, sessionFile: string | null, enabled: boolean) { + this.cwd = cwd; + this.sessionDir = getSessionDirectory(cwd); + this.enabled = enabled; - if (customSessionPath) { - // Use custom session file path - this.sessionFile = resolve(customSessionPath); - this.loadSessionId(); - // If file doesn't exist, loadSessionId() won't set sessionId, so generate one - if (!this.sessionId) { - this.sessionId = uuidv4(); - } - // Mark as initialized since we're loading an existing session + if (sessionFile) { + this.sessionFile = resolve(sessionFile); + this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4(); this.sessionInitialized = existsSync(this.sessionFile); - // Load entries into memory if (this.sessionInitialized) { - this.inMemoryEntries = this.loadEntriesFromFile(); - } - } else if (continueSession) { - const mostRecent = this.findMostRecentlyModifiedSession(); - if (mostRecent) { - this.sessionFile = mostRecent; - this.loadSessionId(); - // Mark as initialized since we're loading an existing session - this.sessionInitialized = true; - // Load entries into memory - this.inMemoryEntries = this.loadEntriesFromFile(); - } else { - this.initNewSession(); + this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); } } else { - this.initNewSession(); + this.sessionId = uuidv4(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + this.sessionInitialized = false; } } - /** Disable session saving (for --no-session mode) */ - disable() { - this.enabled = false; + /** Create a new session for the given directory */ + static create(cwd: string): SessionManager { + return new SessionManager(cwd, null, true); } - /** Check if session persistence is enabled */ - isEnabled(): boolean { - return this.enabled; + /** Open a specific session file */ + static open(path: string): SessionManager { + // Extract cwd from session header if possible, otherwise use process.cwd() + const entries = loadEntriesFromFile(path); + const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; + const cwd = header?.cwd ?? process.cwd(); + return new SessionManager(cwd, path, true); } - private getSessionDirectory(): string { - const cwd = process.cwd(); - // Replace all path separators and colons (for Windows drive letters) with dashes - const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - - const configDir = getAgentDir(); - const sessionDir = join(configDir, "sessions", safePath); - if (!existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); + /** Continue the most recent session for the given directory, or create new if none */ + static continueRecent(cwd: string): SessionManager { + const sessionDir = getSessionDirectory(cwd); + const mostRecent = findMostRecentSession(sessionDir); + if (mostRecent) { + return new SessionManager(cwd, mostRecent, true); } - return sessionDir; + return new SessionManager(cwd, null, true); } - private initNewSession(): void { - this.sessionId = uuidv4(); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + /** Create an in-memory session (no file persistence) */ + static inMemory(): SessionManager { + return new SessionManager(process.cwd(), null, false); } - /** Reset to a fresh session. Clears pending entries and starts a new session file. */ - reset(): void { - this.pendingEntries = []; - this.inMemoryEntries = []; - this.sessionInitialized = false; - this.initNewSession(); - } - - private findMostRecentlyModifiedSession(): string | null { - try { - const files = readdirSync(this.sessionDir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => ({ - name: f, - path: join(this.sessionDir, f), - mtime: statSync(join(this.sessionDir, f)).mtime, - })) - .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - - return files[0]?.path || null; - } catch { - return null; - } - } - - private loadSessionId(): void { - if (!existsSync(this.sessionFile)) return; - - const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n"); - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === "session") { - this.sessionId = entry.id; - return; - } - } catch { - // Skip malformed lines - } - } - this.sessionId = uuidv4(); - } - - startSession(state: AgentState): void { - if (this.sessionInitialized) return; - this.sessionInitialized = true; - - const entry: SessionHeader = { - type: "session", - id: this.sessionId, - timestamp: new Date().toISOString(), - cwd: process.cwd(), - provider: state.model.provider, - modelId: state.model.id, - thinkingLevel: state.thinkingLevel, - }; - - // Always track in memory - this.inMemoryEntries.push(entry); - for (const pending of this.pendingEntries) { - this.inMemoryEntries.push(pending); - } - this.pendingEntries = []; - - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - for (const memEntry of this.inMemoryEntries.slice(1)) { - appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`); - } - } - } - - saveMessage(message: any): void { - const entry: SessionMessageEntry = { - type: "message", - timestamp: new Date().toISOString(), - message, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveThinkingLevelChange(thinkingLevel: string): void { - const entry: ThinkingLevelChangeEntry = { - type: "thinking_level_change", - timestamp: new Date().toISOString(), - thinkingLevel, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveModelChange(provider: string, modelId: string): void { - const entry: ModelChangeEntry = { - type: "model_change", - timestamp: new Date().toISOString(), - provider, - modelId, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveCompaction(entry: CompactionEntry): void { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - - /** - * Load session data (messages, model, thinking level) with compaction support. - */ - loadSession(): LoadedSession { - const entries = this.loadEntries(); - return loadSessionFromEntries(entries); - } - - /** - * @deprecated Use loadSession().messages instead - */ - loadMessages(): AppMessage[] { - return this.loadSession().messages; - } - - /** - * @deprecated Use loadSession().thinkingLevel instead - */ - loadThinkingLevel(): string { - return this.loadSession().thinkingLevel; - } - - /** - * @deprecated Use loadSession().model instead - */ - loadModel(): { provider: string; modelId: string } | null { - return this.loadSession().model; - } - - getSessionId(): string { - return this.sessionId; - } - - getSessionFile(): string { - return this.sessionFile; - } - - /** - * Load entries directly from the session file (internal helper). - */ - private loadEntriesFromFile(): SessionEntry[] { - if (!existsSync(this.sessionFile)) return []; - - const content = readFileSync(this.sessionFile, "utf8"); - const entries: SessionEntry[] = []; - const lines = content.trim().split("\n"); - - for (const line of lines) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line) as SessionEntry; - entries.push(entry); - } catch { - // Skip malformed lines - } - } - - return entries; - } - - /** - * Load all entries from the session file or in-memory store. - * When file persistence is enabled, reads from file (source of truth for resumed sessions). - * When disabled (--no-session), returns in-memory entries. - */ - loadEntries(): SessionEntry[] { - // If file persistence is enabled and file exists, read from file - if (this.enabled && existsSync(this.sessionFile)) { - return this.loadEntriesFromFile(); - } - - // Otherwise return in-memory entries (for --no-session mode) - return [...this.inMemoryEntries]; - } - - /** - * Load all sessions for the current directory with metadata - */ - loadAllSessions(): Array<{ - path: string; - id: string; - created: Date; - modified: Date; - messageCount: number; - firstMessage: string; - allMessagesText: string; - }> { - const sessions: Array<{ - path: string; - id: string; - created: Date; - modified: Date; - messageCount: number; - firstMessage: string; - allMessagesText: string; - }> = []; + /** List all sessions for a directory */ + static list(cwd: string): SessionInfo[] { + const sessionDir = getSessionDirectory(cwd); + const sessions: SessionInfo[] = []; try { - const files = readdirSync(this.sessionDir) + const files = readdirSync(sessionDir) .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(this.sessionDir, f)); + .map((f) => join(sessionDir, f)); for (const file of files) { try { @@ -517,17 +313,14 @@ export class SessionManager { try { const entry = JSON.parse(line); - // Extract session ID from first session entry if (entry.type === "session" && !sessionId) { sessionId = entry.id; created = new Date(entry.timestamp); } - // Count messages and collect all text if (entry.type === "message") { messageCount++; - // Extract text from user and assistant messages if (entry.message.role === "user" || entry.message.role === "assistant") { const textContent = entry.message.content .filter((c: any) => c.type === "text") @@ -537,7 +330,6 @@ export class SessionManager { if (textContent) { allMessages.push(textContent); - // Get first user message for display if (!firstMessage && entry.message.role === "user") { firstMessage = textContent; } @@ -558,42 +350,168 @@ export class SessionManager { firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.join(" "), }); - } catch (error) { + } catch { // Skip files that can't be read - console.error(`Failed to read session file ${file}:`, error); } } - // Sort by modified date (most recent first) sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); - } catch (error) { - console.error("Failed to load sessions:", error); + } catch { + // Return empty list on error } return sessions; } - /** - * Set the session file to an existing session - */ + isEnabled(): boolean { + return this.enabled; + } + + getCwd(): string { + return this.cwd; + } + + getSessionId(): string { + return this.sessionId; + } + + getSessionFile(): string { + return this.sessionFile; + } + + /** Switch to a different session file (used for resume and branching) */ setSessionFile(path: string): void { - this.sessionFile = path; - this.loadSessionId(); - // Mark as initialized since we're loading an existing session - this.sessionInitialized = existsSync(path); - // Load entries into memory for consistency + this.sessionFile = resolve(path); + this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4(); + this.sessionInitialized = existsSync(this.sessionFile); if (this.sessionInitialized) { - this.inMemoryEntries = this.loadEntriesFromFile(); + this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); } else { this.inMemoryEntries = []; } this.pendingEntries = []; } - /** - * Check if we should initialize the session based on message history. - * Session is initialized when we have at least 1 user message and 1 assistant message. - */ + reset(): void { + this.pendingEntries = []; + this.inMemoryEntries = []; + this.sessionInitialized = false; + this.sessionId = uuidv4(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + } + + startSession(state: AgentState): void { + if (this.sessionInitialized) return; + this.sessionInitialized = true; + + const entry: SessionHeader = { + type: "session", + id: this.sessionId, + timestamp: new Date().toISOString(), + cwd: this.cwd, + provider: state.model.provider, + modelId: state.model.id, + thinkingLevel: state.thinkingLevel, + }; + + this.inMemoryEntries.push(entry); + for (const pending of this.pendingEntries) { + this.inMemoryEntries.push(pending); + } + this.pendingEntries = []; + + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + for (const memEntry of this.inMemoryEntries.slice(1)) { + appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`); + } + } + } + + saveMessage(message: any): void { + const entry: SessionMessageEntry = { + type: "message", + timestamp: new Date().toISOString(), + message, + }; + + if (!this.sessionInitialized) { + this.pendingEntries.push(entry); + } else { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + } + + saveThinkingLevelChange(thinkingLevel: string): void { + const entry: ThinkingLevelChangeEntry = { + type: "thinking_level_change", + timestamp: new Date().toISOString(), + thinkingLevel, + }; + + if (!this.sessionInitialized) { + this.pendingEntries.push(entry); + } else { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + } + + saveModelChange(provider: string, modelId: string): void { + const entry: ModelChangeEntry = { + type: "model_change", + timestamp: new Date().toISOString(), + provider, + modelId, + }; + + if (!this.sessionInitialized) { + this.pendingEntries.push(entry); + } else { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + } + + saveCompaction(entry: CompactionEntry): void { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + + loadSession(): LoadedSession { + const entries = this.loadEntries(); + return loadSessionFromEntries(entries); + } + + loadMessages(): AppMessage[] { + return this.loadSession().messages; + } + + loadThinkingLevel(): string { + return this.loadSession().thinkingLevel; + } + + loadModel(): { provider: string; modelId: string } | null { + return this.loadSession().model; + } + + loadEntries(): SessionEntry[] { + if (this.enabled && existsSync(this.sessionFile)) { + return loadEntriesFromFile(this.sessionFile); + } + return [...this.inMemoryEntries]; + } + shouldInitializeSession(messages: any[]): boolean { if (this.sessionInitialized) return false; @@ -603,23 +521,16 @@ export class SessionManager { return userMessages.length >= 1 && assistantMessages.length >= 1; } - /** - * Create a branched session from a specific message index. - * If branchFromIndex is -1, creates an empty session. - * Returns the new session file path. - */ createBranchedSession(state: any, branchFromIndex: number): string { - // Create a new session ID for the branch const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); - // Write session header const entry: SessionHeader = { type: "session", id: newSessionId, timestamp: new Date().toISOString(), - cwd: process.cwd(), + cwd: this.cwd, provider: state.model.provider, modelId: state.model.id, thinkingLevel: state.thinkingLevel, @@ -627,7 +538,6 @@ export class SessionManager { }; appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); - // Write messages up to and including the branch point (if >= 0) if (branchFromIndex >= 0) { const messagesToWrite = state.messages.slice(0, branchFromIndex + 1); for (const message of messagesToWrite) { @@ -643,23 +553,16 @@ export class SessionManager { return newSessionFile; } - /** - * Create a branched session from session entries up to (but not including) a specific entry index. - * This preserves compaction events and all entry types. - * Returns the new session file path, or null if in --no-session mode (in-memory only). - */ createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); - // Build new entries list (up to but not including branch point) const newEntries: SessionEntry[] = []; for (let i = 0; i < branchBeforeIndex; i++) { const entry = entries[i]; if (entry.type === "session") { - // Rewrite session header with new ID and branchedFrom newEntries.push({ ...entry, id: newSessionId, @@ -667,22 +570,18 @@ export class SessionManager { branchedFrom: this.enabled ? this.sessionFile : undefined, }); } else { - // Copy other entries as-is newEntries.push(entry); } } if (this.enabled) { - // Write to file for (const entry of newEntries) { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); } return newSessionFile; - } else { - // In-memory mode: replace inMemoryEntries, no file created - this.inMemoryEntries = newEntries; - this.sessionId = newSessionId; - return null; } + this.inMemoryEntries = newEntries; + this.sessionId = newSessionId; + return null; } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 87e30209..14c207ef 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -117,6 +117,7 @@ export { parseSessionEntries, type SessionEntry, type SessionHeader, + type SessionInfo, SessionManager, type SessionMessageEntry, SUMMARY_PREFIX, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b02ae8a2..217b563c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -29,7 +29,6 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js" import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js"; import { ensureTool } from "./utils/tools-manager.js"; -/** Configure OAuth storage to use the coding-agent's configurable path */ function configureOAuthStorage(): void { const oauthPath = getOAuthPath(); @@ -55,7 +54,6 @@ function configureOAuthStorage(): void { }); } -/** Check npm registry for new version (non-blocking) */ async function checkForNewVersion(currentVersion: string): Promise { try { const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest"); @@ -74,7 +72,6 @@ async function checkForNewVersion(currentVersion: string): Promise { +function createSessionManager(parsed: Args, cwd: string): SessionManager | null { + if (parsed.noSession) { + return SessionManager.inMemory(); + } + if (parsed.session) { + return SessionManager.open(parsed.session); + } + if (parsed.continue) { + return SessionManager.continueRecent(cwd); + } + // --resume is handled separately (needs picker UI) + // Default case (new session) returns null, SDK will create one + return null; +} + +function buildSessionOptions( + parsed: Args, + scopedModels: ScopedModel[], + sessionManager: SessionManager | null, +): CreateAgentSessionOptions { const options: CreateAgentSessionOptions = {}; + if (sessionManager) { + options.sessionManager = sessionManager; + } + // Model from CLI if (parsed.provider && parsed.model) { const { model, error } = findModel(parsed.provider, parsed.model); @@ -201,7 +218,6 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P } options.model = model; } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { - // Use first scoped model options.model = scopedModels[0].model; } @@ -251,23 +267,10 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P options.additionalCustomToolPaths = parsed.customTools; } - // Session handling - if (parsed.noSession) { - options.sessionFile = false; - } else if (parsed.session) { - options.sessionFile = parsed.session; - } - - // Continue session - if (parsed.continue && !parsed.resume) { - options.continueSession = true; - } - return options; } export async function main(args: string[]) { - // Configure OAuth storage first configureOAuthStorage(); const parsed = parseArgs(args); @@ -306,40 +309,38 @@ export async function main(args: string[]) { process.exit(1); } + const cwd = process.cwd(); const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed); const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; - // Initialize theme early const settingsManager = new SettingsManager(); initTheme(settingsManager.getTheme(), isInteractive); - // Resolve scoped models from --models flag let scopedModels: ScopedModel[] = []; if (parsed.models && parsed.models.length > 0) { scopedModels = await resolveModelScope(parsed.models); } + // Create session manager based on CLI flags + let sessionManager = createSessionManager(parsed, cwd); + // Handle --resume: show session picker - let sessionFileFromResume: string | undefined; if (parsed.resume) { - const tempSessionManager = new SessionManager(false, undefined); - const selectedSession = await selectSession(tempSessionManager); - if (!selectedSession) { + const sessions = SessionManager.list(cwd); + if (sessions.length === 0) { + console.log(chalk.dim("No sessions found")); + return; + } + const selectedPath = await selectSession(sessions); + if (!selectedPath) { console.log(chalk.dim("No session selected")); return; } - sessionFileFromResume = selectedSession; - } - - const sessionOptions = await buildSessionOptions(parsed, scopedModels); - - // Apply resume session file - if (sessionFileFromResume) { - sessionOptions.sessionFile = sessionFileFromResume; - sessionOptions.restoreFromSession = true; + sessionManager = SessionManager.open(selectedPath); } + const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager); const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions); if (!isInteractive && !session.model) { @@ -369,7 +370,6 @@ export async function main(args: string[]) { const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null); const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager); - // Show model scope if provided if (scopedModels.length > 0) { const modelList = scopedModels .map((sm) => { diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 678cbf20..8a4939be 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -11,27 +11,17 @@ import { Text, truncateToWidth, } from "@mariozechner/pi-tui"; -import type { SessionManager } from "../../../core/session-manager.js"; +import type { SessionInfo } from "../../../core/session-manager.js"; import { fuzzyFilter } from "../../../utils/fuzzy.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -interface SessionItem { - path: string; - id: string; - created: Date; - modified: Date; - messageCount: number; - firstMessage: string; - allMessagesText: string; -} - /** * Custom session list component with multi-line items and search */ class SessionList implements Component { - private allSessions: SessionItem[] = []; - private filteredSessions: SessionItem[] = []; + private allSessions: SessionInfo[] = []; + private filteredSessions: SessionInfo[] = []; private selectedIndex: number = 0; private searchInput: Input; public onSelect?: (sessionPath: string) => void; @@ -39,7 +29,7 @@ class SessionList implements Component { public onExit: () => void = () => {}; private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) - constructor(sessions: SessionItem[]) { + constructor(sessions: SessionInfo[]) { this.allSessions = sessions; this.filteredSessions = sessions; this.searchInput = new Input(); @@ -176,16 +166,13 @@ export class SessionSelectorComponent extends Container { private sessionList: SessionList; constructor( - sessionManager: SessionManager, + sessions: SessionInfo[], onSelect: (sessionPath: string) => void, onCancel: () => void, onExit: () => void, ) { super(); - // Load all sessions - const sessions = sessionManager.loadAllSessions(); - // Add header this.addChild(new Spacer(1)); this.addChild(new Text(theme.bold("Resume Session"), 1, 0)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3ebf416e..2e5638a4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -32,7 +32,12 @@ import type { HookUIContext } from "../../core/hooks/index.js"; import { isBashExecutionMessage } from "../../core/messages.js"; import { invalidateOAuthCache } from "../../core/model-config.js"; import { listOAuthProviders, login, logout, type OAuthProvider } from "../../core/oauth/index.js"; -import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js"; +import { + getLatestCompactionEntry, + SessionManager, + SUMMARY_PREFIX, + SUMMARY_SUFFIX, +} from "../../core/session-manager.js"; import { loadSkills } from "../../core/skills.js"; import { loadProjectContextFiles } from "../../core/system-prompt.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; @@ -1513,8 +1518,9 @@ export class InteractiveMode { private showSessionSelector(): void { this.showSelector((done) => { + const sessions = SessionManager.list(this.sessionManager.getCwd()); const selector = new SessionSelectorComponent( - this.sessionManager, + sessions, async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 64d26861..b29299f4 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -56,10 +56,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { }, }); - sessionManager = new SessionManager(false); - if (noSession) { - sessionManager.disable(); - } + sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir); const settingsManager = new SettingsManager(tempDir); session = new AgentSession({ diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index c8d3b47e..a2574483 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -60,7 +60,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { }, }); - sessionManager = new SessionManager(false); + sessionManager = SessionManager.create(tempDir); const settingsManager = new SettingsManager(tempDir); session = new AgentSession({ @@ -174,9 +174,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { }, }); - // Create session manager and disable file persistence - const noSessionManager = new SessionManager(false); - noSessionManager.disable(); + // Create in-memory session manager + const noSessionManager = SessionManager.inMemory(); const settingsManager = new SettingsManager(tempDir); From 6d4ff74430d9be9b2639062838057ab6a74f7c5d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 01:31:20 +0100 Subject: [PATCH 08/25] Add agentDir parameter to SessionManager factory methods --- .../coding-agent/src/core/session-manager.ts | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index dbce7623..4a5033df 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -2,7 +2,7 @@ import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core"; import { randomBytes } from "crypto"; import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs"; import { join, resolve } from "path"; -import { getAgentDir } from "../config.js"; +import { getAgentDir as getDefaultAgentDir } from "../config.js"; function uuidv4(): string { const bytes = randomBytes(16); @@ -165,10 +165,9 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { return { messages, thinkingLevel, model }; } -function getSessionDirectory(cwd: string): string { +function getSessionDirectory(cwd: string, agentDir: string): string { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const configDir = getAgentDir(); - const sessionDir = join(configDir, "sessions", safePath); + const sessionDir = join(agentDir, "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -238,9 +237,9 @@ export class SessionManager { private pendingEntries: SessionEntry[] = []; private inMemoryEntries: SessionEntry[] = []; - private constructor(cwd: string, sessionFile: string | null, enabled: boolean) { + private constructor(cwd: string, agentDir: string, sessionFile: string | null, enabled: boolean) { this.cwd = cwd; - this.sessionDir = getSessionDirectory(cwd); + this.sessionDir = getSessionDirectory(cwd, agentDir); this.enabled = enabled; if (sessionFile) { @@ -259,37 +258,37 @@ export class SessionManager { } /** Create a new session for the given directory */ - static create(cwd: string): SessionManager { - return new SessionManager(cwd, null, true); + static create(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager { + return new SessionManager(cwd, agentDir, null, true); } /** Open a specific session file */ - static open(path: string): SessionManager { + static open(path: string, agentDir: string = getDefaultAgentDir()): SessionManager { // Extract cwd from session header if possible, otherwise use process.cwd() const entries = loadEntriesFromFile(path); const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const cwd = header?.cwd ?? process.cwd(); - return new SessionManager(cwd, path, true); + return new SessionManager(cwd, agentDir, path, true); } /** Continue the most recent session for the given directory, or create new if none */ - static continueRecent(cwd: string): SessionManager { - const sessionDir = getSessionDirectory(cwd); + static continueRecent(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager { + const sessionDir = getSessionDirectory(cwd, agentDir); const mostRecent = findMostRecentSession(sessionDir); if (mostRecent) { - return new SessionManager(cwd, mostRecent, true); + return new SessionManager(cwd, agentDir, mostRecent, true); } - return new SessionManager(cwd, null, true); + return new SessionManager(cwd, agentDir, null, true); } /** Create an in-memory session (no file persistence) */ static inMemory(): SessionManager { - return new SessionManager(process.cwd(), null, false); + return new SessionManager(process.cwd(), getDefaultAgentDir(), null, false); } /** List all sessions for a directory */ - static list(cwd: string): SessionInfo[] { - const sessionDir = getSessionDirectory(cwd); + static list(cwd: string, agentDir: string = getDefaultAgentDir()): SessionInfo[] { + const sessionDir = getSessionDirectory(cwd, agentDir); const sessions: SessionInfo[] = []; try { From b168a6cae3ce5543419fe0ba1757a6ae56431850 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 01:42:27 +0100 Subject: [PATCH 09/25] Add agentDir parameter to model config functions and OAuth storage - loadAndMergeModels(agentDir) - loads models.json from agentDir - getAvailableModels(agentDir) - uses agentDir for model discovery - findModel(provider, id, agentDir) - uses agentDir for model lookup - configureOAuthStorage(agentDir) - configures OAuth to use agentDir/oauth.json - createAgentSession calls configureOAuthStorage with options.agentDir - Export configureOAuthStorage from SDK --- .../coding-agent/src/core/model-config.ts | 28 ++++++---- packages/coding-agent/src/core/sdk.ts | 56 +++++++++++++++---- packages/coding-agent/src/index.ts | 3 +- packages/coding-agent/src/main.ts | 34 ++--------- 4 files changed, 69 insertions(+), 52 deletions(-) diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index a188b61e..8208bab3 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -15,7 +15,8 @@ import { import { type Static, Type } from "@sinclair/typebox"; import AjvModule from "ajv"; import { existsSync, readFileSync } from "fs"; -import { getModelsPath } from "../config.js"; +import { join } from "path"; +import { getAgentDir } from "../config.js"; import { getOAuthToken, type OAuthProvider, refreshToken } from "./oauth/index.js"; // Handle both default and named exports @@ -97,8 +98,8 @@ export function resolveApiKey(keyConfig: string): string | undefined { * Load custom models from models.json in agent config dir * Returns { models, error } - either models array or error message */ -function loadCustomModels(): { models: Model[]; error: string | null } { - const configPath = getModelsPath(); +function loadCustomModels(agentDir: string): { models: Model[]; error: string | null } { + const configPath = join(agentDir, "models.json"); if (!existsSync(configPath)) { return { models: [], error: null }; } @@ -232,7 +233,7 @@ function parseModels(config: ModelsConfig): Model[] { * Get all models (built-in + custom), freshly loaded * Returns { models, error } - either models array or error message */ -export function loadAndMergeModels(): { models: Model[]; error: string | null } { +export function loadAndMergeModels(agentDir: string = getAgentDir()): { models: Model[]; error: string | null } { const builtInModels: Model[] = []; const providers = getProviders(); @@ -243,7 +244,7 @@ export function loadAndMergeModels(): { models: Model[]; error: string | nu } // Load custom models - const { models: customModels, error } = loadCustomModels(); + const { models: customModels, error } = loadCustomModels(agentDir); if (error) { return { models: [], error }; @@ -267,7 +268,8 @@ export function loadAndMergeModels(): { models: Model[]; error: string | nu /** * Get API key for a model (checks custom providers first, then built-in) - * Now async to support OAuth token refresh + * Now async to support OAuth token refresh. + * Note: OAuth storage location is configured globally via setOAuthStorage. */ export async function getApiKeyForModel(model: Model): Promise { // For custom providers, check their apiKey config @@ -357,8 +359,10 @@ export async function getApiKeyForModel(model: Model): Promise[]; error: string | null }> { - const { models: allModels, error } = loadAndMergeModels(); +export async function getAvailableModels( + agentDir: string = getAgentDir(), +): Promise<{ models: Model[]; error: string | null }> { + const { models: allModels, error } = loadAndMergeModels(agentDir); if (error) { return { models: [], error }; @@ -390,8 +394,12 @@ export async function getAvailableModels(): Promise<{ models: Model[]; erro * Find a specific model by provider and ID * Returns { model, error } - either model or error message */ -export function findModel(provider: string, modelId: string): { model: Model | null; error: string | null } { - const { models: allModels, error } = loadAndMergeModels(); +export function findModel( + provider: string, + modelId: string, + agentDir: string = getAgentDir(), +): { model: Model | null; error: string | null } { + const { models: allModels, error } = loadAndMergeModels(agentDir); if (error) { return { model: null, error }; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9b2f3220..0ecf4f24 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -30,7 +30,9 @@ */ import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Model } from "@mariozechner/pi-ai"; +import { type Model, setOAuthStorage } from "@mariozechner/pi-ai"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; import { getAgentDir } from "../config.js"; import { AgentSession } from "./agent-session.js"; import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js"; @@ -154,14 +156,42 @@ function getDefaultAgentDir(): string { return getAgentDir(); } +/** + * Configure OAuth storage to use the specified agent directory. + * Must be called before using OAuth-based authentication. + */ +export function configureOAuthStorage(agentDir: string = getDefaultAgentDir()): void { + const oauthPath = join(agentDir, "oauth.json"); + + setOAuthStorage({ + load: () => { + if (!existsSync(oauthPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(oauthPath, "utf-8")); + } catch { + return {}; + } + }, + save: (storage) => { + const dir = dirname(oauthPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + writeFileSync(oauthPath, JSON.stringify(storage, null, 2), "utf-8"); + chmodSync(oauthPath, 0o600); + }, + }); +} + // Discovery Functions /** * Get all models (built-in + custom from models.json). - * Note: Uses default agentDir for models.json location. */ -export function discoverModels(): Model[] { - const { models, error } = loadAndMergeModels(); +export function discoverModels(agentDir: string = getDefaultAgentDir()): Model[] { + const { models, error } = loadAndMergeModels(agentDir); if (error) { throw new Error(error); } @@ -170,10 +200,9 @@ export function discoverModels(): Model[] { /** * Get models that have valid API keys available. - * Note: Uses default agentDir for models.json and oauth.json location. */ -export async function discoverAvailableModels(): Promise[]> { - const { models, error } = await getAvailableModels(); +export async function discoverAvailableModels(agentDir: string = getDefaultAgentDir()): Promise[]> { + const { models, error } = await getAvailableModels(agentDir); if (error) { throw new Error(error); } @@ -182,11 +211,14 @@ export async function discoverAvailableModels(): Promise[]> { /** * Find a model by provider and ID. - * Note: Uses default agentDir for models.json location. * @returns The model, or null if not found */ -export function findModel(provider: string, modelId: string): Model | null { - const { model, error } = findModelInternal(provider, modelId); +export function findModel( + provider: string, + modelId: string, + agentDir: string = getDefaultAgentDir(), +): Model | null { + const { model, error } = findModelInternal(provider, modelId, agentDir); if (error) { throw new Error(error); } @@ -276,7 +308,6 @@ export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlas /** * Create the default API key resolver. * Checks custom providers (models.json), OAuth, and environment variables. - * Note: Uses default agentDir for models.json and oauth.json location. */ export function defaultGetApiKey(): (model: Model) => Promise { return getApiKeyForModel; @@ -415,6 +446,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getDefaultAgentDir(); + // Configure OAuth storage for this agentDir + configureOAuthStorage(agentDir); + const settingsManager = new SettingsManager(agentDir); const sessionManager = options.sessionManager ?? SessionManager.create(cwd); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 14c207ef..93056daa 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -85,11 +85,12 @@ export { } from "./core/oauth/index.js"; // SDK for programmatic usage export { - allBuiltInTools, type BuildSystemPromptOptions, buildSystemPrompt, type CreateAgentSessionOptions, type CreateAgentSessionResult, + // Configuration + configureOAuthStorage, // Factory createAgentSession, // Helpers diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 217b563c..37bb13aa 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -6,21 +6,20 @@ */ import type { Attachment } from "@mariozechner/pi-agent-core"; -import { setOAuthStorage, supportsXhigh } from "@mariozechner/pi-ai"; +import { supportsXhigh } from "@mariozechner/pi-ai"; import chalk from "chalk"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { dirname } from "path"; +import { existsSync, readFileSync } from "fs"; import { type Args, parseArgs, printHelp } from "./cli/args.js"; import { processFileArguments } from "./cli/file-processor.js"; import { listModels } from "./cli/list-models.js"; import { selectSession } from "./cli/session-picker.js"; -import { getModelsPath, getOAuthPath, VERSION } from "./config.js"; +import { getModelsPath, VERSION } from "./config.js"; import type { AgentSession } from "./core/agent-session.js"; import type { LoadedCustomTool } from "./core/custom-tools/index.js"; import { exportFromFile } from "./core/export-html.js"; import { findModel } from "./core/model-config.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; -import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js"; +import { type CreateAgentSessionOptions, configureOAuthStorage, createAgentSession } from "./core/sdk.js"; import { SessionManager } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; import { allTools } from "./core/tools/index.js"; @@ -29,31 +28,6 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js" import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js"; import { ensureTool } from "./utils/tools-manager.js"; -function configureOAuthStorage(): void { - const oauthPath = getOAuthPath(); - - setOAuthStorage({ - load: () => { - if (!existsSync(oauthPath)) { - return {}; - } - try { - return JSON.parse(readFileSync(oauthPath, "utf-8")); - } catch { - return {}; - } - }, - save: (storage) => { - const dir = dirname(oauthPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - writeFileSync(oauthPath, JSON.stringify(storage, null, 2), "utf-8"); - chmodSync(oauthPath, 0o600); - }, - }); -} - async function checkForNewVersion(currentVersion: string): Promise { try { const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest"); From d67559a5e48eec625fa749d01610912ae451995d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 01:42:52 +0100 Subject: [PATCH 10/25] Remove unused fs import from main.ts --- packages/coding-agent/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 37bb13aa..5fa89a5a 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -8,7 +8,7 @@ import type { Attachment } from "@mariozechner/pi-agent-core"; import { supportsXhigh } from "@mariozechner/pi-ai"; import chalk from "chalk"; -import { existsSync, readFileSync } from "fs"; + import { type Args, parseArgs, printHelp } from "./cli/args.js"; import { processFileArguments } from "./cli/file-processor.js"; import { listModels } from "./cli/list-models.js"; From 1236ab8007439e3dfb665fdf66fb4b6181f17a64 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 01:44:54 +0100 Subject: [PATCH 11/25] Add default agentDir to loadCustomModels for consistency --- packages/coding-agent/src/core/model-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 8208bab3..d9b82260 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -98,7 +98,7 @@ export function resolveApiKey(keyConfig: string): string | undefined { * Load custom models from models.json in agent config dir * Returns { models, error } - either models array or error message */ -function loadCustomModels(agentDir: string): { models: Model[]; error: string | null } { +function loadCustomModels(agentDir: string = getAgentDir()): { models: Model[]; error: string | null } { const configPath = join(agentDir, "models.json"); if (!existsSync(configPath)) { return { models: [], error: null }; From f7a12c478cb77670b4270bbeab7869c871d6c11a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 01:52:58 +0100 Subject: [PATCH 12/25] Clean up agentDir handling and remove redundant code - Use default params instead of resolvedAgentDir pattern in loaders - Remove unused DiscoverAndLoadHooksOptions interface - Remove redundant github-copilot special casing in getAvailableModels --- .../coding-agent/src/core/custom-tools/loader.ts | 5 ++--- packages/coding-agent/src/core/hooks/loader.ts | 14 ++------------ packages/coding-agent/src/core/model-config.ts | 11 ----------- 3 files changed, 4 insertions(+), 26 deletions(-) diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 7af81acd..a56b3443 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -317,9 +317,8 @@ export async function discoverAndLoadCustomTools( configuredPaths: string[], cwd: string, builtInToolNames: string[], - agentDir?: string, + agentDir: string = getAgentDir(), ): Promise { - const resolvedAgentDir = agentDir ?? getAgentDir(); const allPaths: string[] = []; const seen = new Set(); @@ -335,7 +334,7 @@ export async function discoverAndLoadCustomTools( }; // 1. Global tools: agentDir/tools/ - const globalToolsDir = path.join(resolvedAgentDir, "tools"); + const globalToolsDir = path.join(agentDir, "tools"); addPaths(discoverToolsInDir(globalToolsDir)); // 2. Project-local tools: cwd/.pi/tools/ diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 4f5273c0..1fdd8ffa 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -215,15 +215,6 @@ function discoverHooksInDir(dir: string): string[] { } } -export interface DiscoverAndLoadHooksOptions { - /** Explicit paths from settings.json or CLI */ - configuredPaths?: string[]; - /** Current working directory */ - cwd?: string; - /** Agent config directory. Default: from getAgentDir() */ - agentDir?: string; -} - /** * Discover and load hooks from standard locations: * 1. agentDir/hooks/*.ts (global) @@ -234,9 +225,8 @@ export interface DiscoverAndLoadHooksOptions { export async function discoverAndLoadHooks( configuredPaths: string[], cwd: string, - agentDir?: string, + agentDir: string = getAgentDir(), ): Promise { - const resolvedAgentDir = agentDir ?? getAgentDir(); const allPaths: string[] = []; const seen = new Set(); @@ -252,7 +242,7 @@ export async function discoverAndLoadHooks( }; // 1. Global hooks: agentDir/hooks/ - const globalHooksDir = path.join(resolvedAgentDir, "hooks"); + const globalHooksDir = path.join(agentDir, "hooks"); addPaths(discoverHooksInDir(globalHooksDir)); // 2. Project-local hooks: cwd/.pi/hooks/ diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index d9b82260..04933e91 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -369,18 +369,7 @@ export async function getAvailableModels( } const availableModels: Model[] = []; - const copilotCreds = loadOAuthCredentials("github-copilot"); - const hasCopilotEnv = !!(process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN); - const hasCopilot = !!copilotCreds || hasCopilotEnv; - for (const model of allModels) { - if (model.provider === "github-copilot") { - if (hasCopilot) { - availableModels.push(model); - } - continue; - } - const apiKey = await getApiKeyForModel(model); if (apiKey) { availableModels.push(model); From 0faadfcd003d082bac6f7ae738ba6e457d92a378 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 02:43:38 +0100 Subject: [PATCH 13/25] Fix session-manager simplification issues - Remove unused inspector import from session-manager.ts - Remove dead code in _persist() - Update tests for simplified SessionHeader - Update mom context.ts: remove unused AgentState import, fix startSession(), rename isEnabled to isPersisted --- .../coding-agent/src/core/agent-session.ts | 17 +- .../coding-agent/src/core/session-manager.ts | 414 +++++++----------- packages/coding-agent/test/compaction.test.ts | 20 +- packages/mom/src/context.ts | 16 +- 4 files changed, 162 insertions(+), 305 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5000c441..6570dc46 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -202,11 +202,6 @@ export class AgentSession { if (event.type === "message_end") { this.sessionManager.saveMessage(event.message); - // Initialize session after first user+assistant exchange - if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) { - this.sessionManager.startSession(this.agent.state); - } - // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { this._lastAssistantMessage = event.message; @@ -389,7 +384,7 @@ export class AgentSession { /** Current session file path, or null if sessions are disabled */ get sessionFile(): string | null { - return this.sessionManager.isEnabled() ? this.sessionManager.getSessionFile() : null; + return this.sessionManager.isPersisted() ? this.sessionManager.getSessionFile() : null; } /** Current session ID */ @@ -1096,11 +1091,6 @@ export class AgentSession { // Save to session this.sessionManager.saveMessage(bashMessage); - - // Initialize session if needed - if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) { - this.sessionManager.startSession(this.agent.state); - } } return result; @@ -1141,11 +1131,6 @@ export class AgentSession { this.sessionManager.saveMessage(bashMessage); } - // Initialize session if needed - if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) { - this.sessionManager.startSession(this.agent.state); - } - this._pendingBashMessages = []; } diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 4a5033df..1c1b4953 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -17,9 +17,6 @@ export interface SessionHeader { id: string; timestamp: string; cwd: string; - provider: string; - modelId: string; - thinkingLevel: string; branchedFrom?: string; } @@ -120,13 +117,12 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { let model: { provider: string; modelId: string } | null = null; for (const entry of entries) { - if (entry.type === "session") { - thinkingLevel = entry.thinkingLevel; - model = { provider: entry.provider, modelId: entry.modelId }; - } else if (entry.type === "thinking_level_change") { + if (entry.type === "thinking_level_change") { thinkingLevel = entry.thinkingLevel; } else if (entry.type === "model_change") { model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; } } @@ -194,23 +190,6 @@ function loadEntriesFromFile(filePath: string): SessionEntry[] { return entries; } -function extractSessionIdFromFile(filePath: string): string | null { - if (!existsSync(filePath)) return null; - - const lines = readFileSync(filePath, "utf8").trim().split("\n"); - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === "session") { - return entry.id; - } - } catch { - // Skip malformed lines - } - } - return null; -} - function findMostRecentSession(sessionDir: string): string | null { try { const files = readdirSync(sessionDir) @@ -228,35 +207,170 @@ function findMostRecentSession(sessionDir: string): string | null { } export class SessionManager { - private sessionId: string; - private sessionFile: string; + private sessionId: string = ""; + private sessionFile: string = ""; private sessionDir: string; private cwd: string; - private enabled: boolean; - private sessionInitialized: boolean; - private pendingEntries: SessionEntry[] = []; + private persist: boolean; private inMemoryEntries: SessionEntry[] = []; - private constructor(cwd: string, agentDir: string, sessionFile: string | null, enabled: boolean) { + private constructor(cwd: string, agentDir: string, sessionFile: string | null, persist: boolean) { this.cwd = cwd; this.sessionDir = getSessionDirectory(cwd, agentDir); - this.enabled = enabled; + this.persist = persist; if (sessionFile) { - this.sessionFile = resolve(sessionFile); - this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4(); - this.sessionInitialized = existsSync(this.sessionFile); - if (this.sessionInitialized) { - this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); - } + this.setSessionFile(sessionFile); } else { this.sessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); - this.sessionInitialized = false; + const sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + this.setSessionFile(sessionFile); } } + /** Switch to a different session file (used for resume and branching) */ + setSessionFile(sessionFile: string): void { + this.sessionFile = resolve(sessionFile); + if (existsSync(this.sessionFile)) { + this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); + const header = this.inMemoryEntries.find((e) => e.type === "session"); + this.sessionId = header ? (header as SessionHeader).id : uuidv4(); + } else { + this.sessionId = uuidv4(); + this.inMemoryEntries = []; + const entry: SessionHeader = { + type: "session", + id: this.sessionId, + timestamp: new Date().toISOString(), + cwd: this.cwd, + }; + this.inMemoryEntries.push(entry); + } + } + + isPersisted(): boolean { + return this.persist; + } + + getCwd(): string { + return this.cwd; + } + + getSessionId(): string { + return this.sessionId; + } + + getSessionFile(): string { + return this.sessionFile; + } + + reset(): void { + this.inMemoryEntries = []; + this.sessionId = uuidv4(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + } + + _persist(entry: SessionEntry): void { + if (this.persist && this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant")) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + + saveMessage(message: any): void { + const entry: SessionMessageEntry = { + type: "message", + timestamp: new Date().toISOString(), + message, + }; + this.inMemoryEntries.push(entry); + this._persist(entry); + } + + saveThinkingLevelChange(thinkingLevel: string): void { + const entry: ThinkingLevelChangeEntry = { + type: "thinking_level_change", + timestamp: new Date().toISOString(), + thinkingLevel, + }; + this.inMemoryEntries.push(entry); + this._persist(entry); + } + + saveModelChange(provider: string, modelId: string): void { + const entry: ModelChangeEntry = { + type: "model_change", + timestamp: new Date().toISOString(), + provider, + modelId, + }; + this.inMemoryEntries.push(entry); + this._persist(entry); + } + + saveCompaction(entry: CompactionEntry): void { + this.inMemoryEntries.push(entry); + this._persist(entry); + } + + loadSession(): LoadedSession { + const entries = this.loadEntries(); + return loadSessionFromEntries(entries); + } + + loadMessages(): AppMessage[] { + return this.loadSession().messages; + } + + loadThinkingLevel(): string { + return this.loadSession().thinkingLevel; + } + + loadModel(): { provider: string; modelId: string } | null { + return this.loadSession().model; + } + + loadEntries(): SessionEntry[] { + if (this.inMemoryEntries.length > 0) { + return [...this.inMemoryEntries]; + } else { + return loadEntriesFromFile(this.sessionFile); + } + } + + createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { + const newSessionId = uuidv4(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); + + const newEntries: SessionEntry[] = []; + for (let i = 0; i < branchBeforeIndex; i++) { + const entry = entries[i]; + + if (entry.type === "session") { + newEntries.push({ + ...entry, + id: newSessionId, + timestamp: new Date().toISOString(), + branchedFrom: this.persist ? this.sessionFile : undefined, + }); + } else { + newEntries.push(entry); + } + } + + if (this.persist) { + for (const entry of newEntries) { + appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); + } + return newSessionFile; + } + this.inMemoryEntries = newEntries; + this.sessionId = newSessionId; + return null; + } + /** Create a new session for the given directory */ static create(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager { return new SessionManager(cwd, agentDir, null, true); @@ -361,226 +475,4 @@ export class SessionManager { return sessions; } - - isEnabled(): boolean { - return this.enabled; - } - - getCwd(): string { - return this.cwd; - } - - getSessionId(): string { - return this.sessionId; - } - - getSessionFile(): string { - return this.sessionFile; - } - - /** Switch to a different session file (used for resume and branching) */ - setSessionFile(path: string): void { - this.sessionFile = resolve(path); - this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4(); - this.sessionInitialized = existsSync(this.sessionFile); - if (this.sessionInitialized) { - this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); - } else { - this.inMemoryEntries = []; - } - this.pendingEntries = []; - } - - reset(): void { - this.pendingEntries = []; - this.inMemoryEntries = []; - this.sessionInitialized = false; - this.sessionId = uuidv4(); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); - } - - startSession(state: AgentState): void { - if (this.sessionInitialized) return; - this.sessionInitialized = true; - - const entry: SessionHeader = { - type: "session", - id: this.sessionId, - timestamp: new Date().toISOString(), - cwd: this.cwd, - provider: state.model.provider, - modelId: state.model.id, - thinkingLevel: state.thinkingLevel, - }; - - this.inMemoryEntries.push(entry); - for (const pending of this.pendingEntries) { - this.inMemoryEntries.push(pending); - } - this.pendingEntries = []; - - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - for (const memEntry of this.inMemoryEntries.slice(1)) { - appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`); - } - } - } - - saveMessage(message: any): void { - const entry: SessionMessageEntry = { - type: "message", - timestamp: new Date().toISOString(), - message, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - this.inMemoryEntries.push(entry); - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveThinkingLevelChange(thinkingLevel: string): void { - const entry: ThinkingLevelChangeEntry = { - type: "thinking_level_change", - timestamp: new Date().toISOString(), - thinkingLevel, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - this.inMemoryEntries.push(entry); - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveModelChange(provider: string, modelId: string): void { - const entry: ModelChangeEntry = { - type: "model_change", - timestamp: new Date().toISOString(), - provider, - modelId, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - this.inMemoryEntries.push(entry); - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveCompaction(entry: CompactionEntry): void { - this.inMemoryEntries.push(entry); - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - - loadSession(): LoadedSession { - const entries = this.loadEntries(); - return loadSessionFromEntries(entries); - } - - loadMessages(): AppMessage[] { - return this.loadSession().messages; - } - - loadThinkingLevel(): string { - return this.loadSession().thinkingLevel; - } - - loadModel(): { provider: string; modelId: string } | null { - return this.loadSession().model; - } - - loadEntries(): SessionEntry[] { - if (this.enabled && existsSync(this.sessionFile)) { - return loadEntriesFromFile(this.sessionFile); - } - return [...this.inMemoryEntries]; - } - - shouldInitializeSession(messages: any[]): boolean { - if (this.sessionInitialized) return false; - - const userMessages = messages.filter((m) => m.role === "user"); - const assistantMessages = messages.filter((m) => m.role === "assistant"); - - return userMessages.length >= 1 && assistantMessages.length >= 1; - } - - createBranchedSession(state: any, branchFromIndex: number): string { - const newSessionId = uuidv4(); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); - - const entry: SessionHeader = { - type: "session", - id: newSessionId, - timestamp: new Date().toISOString(), - cwd: this.cwd, - provider: state.model.provider, - modelId: state.model.id, - thinkingLevel: state.thinkingLevel, - branchedFrom: this.sessionFile, - }; - appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); - - if (branchFromIndex >= 0) { - const messagesToWrite = state.messages.slice(0, branchFromIndex + 1); - for (const message of messagesToWrite) { - const messageEntry: SessionMessageEntry = { - type: "message", - timestamp: new Date().toISOString(), - message, - }; - appendFileSync(newSessionFile, `${JSON.stringify(messageEntry)}\n`); - } - } - - return newSessionFile; - } - - createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { - const newSessionId = uuidv4(); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); - - const newEntries: SessionEntry[] = []; - for (let i = 0; i < branchBeforeIndex; i++) { - const entry = entries[i]; - - if (entry.type === "session") { - newEntries.push({ - ...entry, - id: newSessionId, - timestamp: new Date().toISOString(), - branchedFrom: this.enabled ? this.sessionFile : undefined, - }); - } else { - newEntries.push(entry); - } - } - - if (this.enabled) { - for (const entry of newEntries) { - appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); - } - return newSessionFile; - } - this.inMemoryEntries = newEntries; - this.sessionId = newSessionId; - return null; - } } diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 64eebcf0..9fe3d1b0 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -234,9 +234,6 @@ describe("loadSessionFromEntries", () => { id: "1", timestamp: "", cwd: "", - provider: "anthropic", - modelId: "claude", - thinkingLevel: "off", }, createMessageEntry(createUserMessage("1")), createMessageEntry(createAssistantMessage("a")), @@ -247,7 +244,7 @@ describe("loadSessionFromEntries", () => { const loaded = loadSessionFromEntries(entries); expect(loaded.messages.length).toBe(4); expect(loaded.thinkingLevel).toBe("off"); - expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude" }); + expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); }); it("should handle single compaction", () => { @@ -258,9 +255,6 @@ describe("loadSessionFromEntries", () => { id: "1", timestamp: "", cwd: "", - provider: "anthropic", - modelId: "claude", - thinkingLevel: "off", }, createMessageEntry(createUserMessage("1")), createMessageEntry(createAssistantMessage("a")), @@ -286,9 +280,6 @@ describe("loadSessionFromEntries", () => { id: "1", timestamp: "", cwd: "", - provider: "anthropic", - modelId: "claude", - thinkingLevel: "off", }, createMessageEntry(createUserMessage("1")), createMessageEntry(createAssistantMessage("a")), @@ -316,9 +307,6 @@ describe("loadSessionFromEntries", () => { id: "1", timestamp: "", cwd: "", - provider: "anthropic", - modelId: "claude", - thinkingLevel: "off", }, createMessageEntry(createUserMessage("1")), createMessageEntry(createAssistantMessage("a")), @@ -341,9 +329,6 @@ describe("loadSessionFromEntries", () => { id: "1", timestamp: "", cwd: "", - provider: "anthropic", - modelId: "claude", - thinkingLevel: "off", }, createMessageEntry(createUserMessage("1")), { type: "model_change", timestamp: "", provider: "openai", modelId: "gpt-4" }, @@ -352,7 +337,8 @@ describe("loadSessionFromEntries", () => { ]; const loaded = loadSessionFromEntries(entries); - expect(loaded.model).toEqual({ provider: "openai", modelId: "gpt-4" }); + // model_change is later overwritten by assistant message's model info + expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); expect(loaded.thinkingLevel).toBe("high"); }); }); diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index ee93298a..16eb8e35 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -10,7 +10,7 @@ * - MomSettingsManager: Simple settings for mom (compaction, retry, model preferences) */ -import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core"; +import type { AppMessage } from "@mariozechner/pi-agent-core"; import { type CompactionEntry, type LoadedSession, @@ -71,14 +71,14 @@ export class MomSessionManager { // New session - write header immediately this.sessionId = uuidv4(); if (initialModel) { - this.writeSessionHeader(initialModel); + this.writeSessionHeader(); } } // Note: syncFromLog() is called explicitly from agent.ts with excludeTimestamp } /** Write session header to file (called on new session creation) */ - private writeSessionHeader(model: { provider: string; id: string; thinkingLevel?: string }): void { + private writeSessionHeader(): void { this.sessionInitialized = true; const entry: SessionHeader = { @@ -86,9 +86,6 @@ export class MomSessionManager { id: this.sessionId, timestamp: new Date().toISOString(), cwd: this.channelDir, - provider: model.provider, - modelId: model.id, - thinkingLevel: model.thinkingLevel || "off", }; this.inMemoryEntries.push(entry); @@ -249,7 +246,7 @@ export class MomSessionManager { } /** Initialize session with header if not already done */ - startSession(state: AgentState): void { + startSession(): void { if (this.sessionInitialized) return; this.sessionInitialized = true; @@ -258,9 +255,6 @@ export class MomSessionManager { id: this.sessionId, timestamp: new Date().toISOString(), cwd: this.channelDir, - provider: state.model?.provider || "unknown", - modelId: state.model?.id || "unknown", - thinkingLevel: state.thinkingLevel, }; this.inMemoryEntries.push(entry); @@ -370,7 +364,7 @@ export class MomSessionManager { } // Compatibility methods for AgentSession - isEnabled(): boolean { + isPersisted(): boolean { return true; } From 974c8f57e5e8bb2e90f21bb0c0215403569cfab3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 02:43:56 +0100 Subject: [PATCH 14/25] Remove unused AgentState import from session-manager.ts --- packages/coding-agent/src/core/session-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 1c1b4953..115740d1 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,4 +1,4 @@ -import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core"; +import type { AppMessage } from "@mariozechner/pi-agent-core"; import { randomBytes } from "crypto"; import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs"; import { join, resolve } from "path"; From 184c648334a078f58f056e4432402b82280ae03d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 02:51:56 +0100 Subject: [PATCH 15/25] Fix session persistence: flush all buffered entries on first assistant message - SessionManager: add flushed flag, _persist() flushes all entries when first assistant seen - SessionManager: reset() now creates session header - MomSessionManager: same pattern, remove pendingEntries/sessionInitialized/startSession/shouldInitializeSession --- .../coding-agent/src/core/session-manager.ts | 25 +++- packages/mom/src/agent.ts | 7 +- packages/mom/src/context.ts | 116 ++++++------------ 3 files changed, 62 insertions(+), 86 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 115740d1..25c0bddf 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -212,6 +212,7 @@ export class SessionManager { private sessionDir: string; private cwd: string; private persist: boolean; + private flushed: boolean = false; private inMemoryEntries: SessionEntry[] = []; private constructor(cwd: string, agentDir: string, sessionFile: string | null, persist: boolean) { @@ -236,9 +237,11 @@ export class SessionManager { this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); const header = this.inMemoryEntries.find((e) => e.type === "session"); this.sessionId = header ? (header as SessionHeader).id : uuidv4(); + this.flushed = true; } else { this.sessionId = uuidv4(); this.inMemoryEntries = []; + this.flushed = false; const entry: SessionHeader = { type: "session", id: this.sessionId, @@ -266,14 +269,32 @@ export class SessionManager { } reset(): void { - this.inMemoryEntries = []; this.sessionId = uuidv4(); + this.flushed = false; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + this.inMemoryEntries = [ + { + type: "session", + id: this.sessionId, + timestamp: new Date().toISOString(), + cwd: this.cwd, + }, + ]; } _persist(entry: SessionEntry): void { - if (this.persist && this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant")) { + if (!this.persist) return; + + const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant"); + if (!hasAssistant) return; + + if (!this.flushed) { + for (const e of this.inMemoryEntries) { + appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`); + } + this.flushed = true; + } else { appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); } } diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index 87152924..86124b24 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -411,12 +411,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, [], [], skills); // Create session manager and settings manager - // Pass model info so new sessions get a header written immediately - const sessionManager = new MomSessionManager(channelDir, { - provider: model.provider, - id: model.id, - thinkingLevel: "off", - }); + const sessionManager = new MomSessionManager(channelDir); const settingsManager = new MomSettingsManager(join(channelDir, "..")); // Create agent diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 16eb8e35..4dc0fbd0 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -48,11 +48,10 @@ export class MomSessionManager { private contextFile: string; private logFile: string; private channelDir: string; - private sessionInitialized: boolean = false; + private flushed: boolean = false; private inMemoryEntries: SessionEntry[] = []; - private pendingEntries: SessionEntry[] = []; - constructor(channelDir: string, initialModel?: { provider: string; id: string; thinkingLevel?: string }) { + constructor(channelDir: string) { this.channelDir = channelDir; this.contextFile = join(channelDir, "context.jsonl"); this.logFile = join(channelDir, "log.jsonl"); @@ -66,30 +65,33 @@ export class MomSessionManager { if (existsSync(this.contextFile)) { this.inMemoryEntries = this.loadEntriesFromFile(); this.sessionId = this.extractSessionId() || uuidv4(); - this.sessionInitialized = this.inMemoryEntries.length > 0; + this.flushed = true; } else { - // New session - write header immediately this.sessionId = uuidv4(); - if (initialModel) { - this.writeSessionHeader(); - } + this.inMemoryEntries = [ + { + type: "session", + id: this.sessionId, + timestamp: new Date().toISOString(), + cwd: this.channelDir, + }, + ]; } // Note: syncFromLog() is called explicitly from agent.ts with excludeTimestamp } - /** Write session header to file (called on new session creation) */ - private writeSessionHeader(): void { - this.sessionInitialized = true; + private _persist(entry: SessionEntry): void { + const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant"); + if (!hasAssistant) return; - const entry: SessionHeader = { - type: "session", - id: this.sessionId, - timestamp: new Date().toISOString(), - cwd: this.channelDir, - }; - - this.inMemoryEntries.push(entry); - appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); + if (!this.flushed) { + for (const e of this.inMemoryEntries) { + appendFileSync(this.contextFile, `${JSON.stringify(e)}\n`); + } + this.flushed = true; + } else { + appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); + } } /** @@ -245,44 +247,14 @@ export class MomSessionManager { return entries; } - /** Initialize session with header if not already done */ - startSession(): void { - if (this.sessionInitialized) return; - this.sessionInitialized = true; - - const entry: SessionHeader = { - type: "session", - id: this.sessionId, - timestamp: new Date().toISOString(), - cwd: this.channelDir, - }; - - this.inMemoryEntries.push(entry); - for (const pending of this.pendingEntries) { - this.inMemoryEntries.push(pending); - } - this.pendingEntries = []; - - // Write to file - appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); - for (const memEntry of this.inMemoryEntries.slice(1)) { - appendFileSync(this.contextFile, `${JSON.stringify(memEntry)}\n`); - } - } - saveMessage(message: AppMessage): void { const entry: SessionMessageEntry = { type: "message", timestamp: new Date().toISOString(), message, }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - this.inMemoryEntries.push(entry); - appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); - } + this.inMemoryEntries.push(entry); + this._persist(entry); } saveThinkingLevelChange(thinkingLevel: string): void { @@ -291,13 +263,8 @@ export class MomSessionManager { timestamp: new Date().toISOString(), thinkingLevel, }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - this.inMemoryEntries.push(entry); - appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); - } + this.inMemoryEntries.push(entry); + this._persist(entry); } saveModelChange(provider: string, modelId: string): void { @@ -307,18 +274,13 @@ export class MomSessionManager { provider, modelId, }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - this.inMemoryEntries.push(entry); - appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); - } + this.inMemoryEntries.push(entry); + this._persist(entry); } saveCompaction(entry: CompactionEntry): void { this.inMemoryEntries.push(entry); - appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); + this._persist(entry); } /** Load session with compaction support */ @@ -343,20 +305,18 @@ export class MomSessionManager { return this.contextFile; } - /** Check if session should be initialized */ - shouldInitializeSession(messages: AppMessage[]): boolean { - if (this.sessionInitialized) return false; - const userMessages = messages.filter((m) => m.role === "user"); - const assistantMessages = messages.filter((m) => m.role === "assistant"); - return userMessages.length >= 1 && assistantMessages.length >= 1; - } - /** Reset session (clears context.jsonl) */ reset(): void { - this.pendingEntries = []; - this.inMemoryEntries = []; - this.sessionInitialized = false; this.sessionId = uuidv4(); + this.flushed = false; + this.inMemoryEntries = [ + { + type: "session", + id: this.sessionId, + timestamp: new Date().toISOString(), + cwd: this.channelDir, + }, + ]; // Truncate the context file if (existsSync(this.contextFile)) { writeFileSync(this.contextFile, ""); From 86cfe6a4365d0caa3e28ea08e247b78b4554cd82 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 02:52:18 +0100 Subject: [PATCH 16/25] Remove unused SessionHeader import from mom/context.ts --- packages/mom/src/context.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 4dc0fbd0..92a3427d 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -17,7 +17,6 @@ import { loadSessionFromEntries, type ModelChangeEntry, type SessionEntry, - type SessionHeader, type SessionMessageEntry, type ThinkingLevelChangeEntry, } from "@mariozechner/pi-coding-agent"; From 56121dcac1b85334d24051196909e9003807db9c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 03:14:30 +0100 Subject: [PATCH 17/25] Add SDK usage examples 12 examples showing increasing levels of customization: - 01-minimal: all defaults - 02-custom-model: model and thinking level - 03-custom-prompt: replace or modify prompt - 04-skills: discover, filter, merge skills - 05-tools: built-in tools, custom tools - 06-hooks: logging, blocking, result modification - 07-context-files: AGENTS.md files - 08-slash-commands: file-based commands - 09-api-keys-and-oauth: API key resolution, OAuth config - 10-settings: compaction, retry, terminal settings - 11-sessions: persistence options - 12-full-control: replace everything Also exports FileSlashCommand type from index.ts --- packages/coding-agent/examples/README.md | 29 ++++ .../coding-agent/examples/sdk/01-minimal.ts | 22 +++ .../examples/sdk/02-custom-model.ts | 36 +++++ .../examples/sdk/03-custom-prompt.ts | 44 ++++++ .../coding-agent/examples/sdk/04-skills.ts | 44 ++++++ .../coding-agent/examples/sdk/05-tools.ts | 67 +++++++++ .../coding-agent/examples/sdk/06-hooks.ts | 61 ++++++++ .../examples/sdk/07-context-files.ts | 36 +++++ .../examples/sdk/08-slash-commands.ts | 37 +++++ .../examples/sdk/09-api-keys-and-oauth.ts | 45 ++++++ .../coding-agent/examples/sdk/10-settings.ts | 33 +++++ .../coding-agent/examples/sdk/11-sessions.ts | 46 ++++++ .../examples/sdk/12-full-control.ts | 84 +++++++++++ packages/coding-agent/examples/sdk/README.md | 138 ++++++++++++++++++ packages/coding-agent/src/index.ts | 1 + 15 files changed, 723 insertions(+) create mode 100644 packages/coding-agent/examples/README.md create mode 100644 packages/coding-agent/examples/sdk/01-minimal.ts create mode 100644 packages/coding-agent/examples/sdk/02-custom-model.ts create mode 100644 packages/coding-agent/examples/sdk/03-custom-prompt.ts create mode 100644 packages/coding-agent/examples/sdk/04-skills.ts create mode 100644 packages/coding-agent/examples/sdk/05-tools.ts create mode 100644 packages/coding-agent/examples/sdk/06-hooks.ts create mode 100644 packages/coding-agent/examples/sdk/07-context-files.ts create mode 100644 packages/coding-agent/examples/sdk/08-slash-commands.ts create mode 100644 packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts create mode 100644 packages/coding-agent/examples/sdk/10-settings.ts create mode 100644 packages/coding-agent/examples/sdk/11-sessions.ts create mode 100644 packages/coding-agent/examples/sdk/12-full-control.ts create mode 100644 packages/coding-agent/examples/sdk/README.md diff --git a/packages/coding-agent/examples/README.md b/packages/coding-agent/examples/README.md new file mode 100644 index 00000000..9b9ba0c6 --- /dev/null +++ b/packages/coding-agent/examples/README.md @@ -0,0 +1,29 @@ +# Examples + +Example code for pi-coding-agent. + +## Directories + +### [sdk/](sdk/) +Programmatic usage via `createAgentSession()`. Shows how to customize models, prompts, tools, hooks, and session management. + +### [hooks/](hooks/) +Example hooks for intercepting tool calls, adding safety gates, and integrating with external systems. + +### [custom-tools/](custom-tools/) +Example custom tools that extend the agent's capabilities. + +## Running Examples + +```bash +cd packages/coding-agent +npx tsx examples/sdk/01-minimal.ts +npx tsx examples/hooks/permission-gate.ts +``` + +## Documentation + +- [SDK Reference](sdk/README.md) +- [Hooks Documentation](../docs/hooks.md) +- [Custom Tools Documentation](../docs/custom-tools.md) +- [Skills Documentation](../docs/skills.md) diff --git a/packages/coding-agent/examples/sdk/01-minimal.ts b/packages/coding-agent/examples/sdk/01-minimal.ts new file mode 100644 index 00000000..b257fccc --- /dev/null +++ b/packages/coding-agent/examples/sdk/01-minimal.ts @@ -0,0 +1,22 @@ +/** + * Minimal SDK Usage + * + * Uses all defaults: discovers skills, hooks, tools, context files + * from cwd and ~/.pi/agent. Model chosen from settings or first available. + */ + +import { createAgentSession } from "../../src/index.js"; + +const { session } = await createAgentSession(); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("What files are in the current directory?"); +session.state.messages.forEach((msg) => { + console.log(msg); +}); +console.log(); diff --git a/packages/coding-agent/examples/sdk/02-custom-model.ts b/packages/coding-agent/examples/sdk/02-custom-model.ts new file mode 100644 index 00000000..4a2690da --- /dev/null +++ b/packages/coding-agent/examples/sdk/02-custom-model.ts @@ -0,0 +1,36 @@ +/** + * Custom Model Selection + * + * Shows how to select a specific model and thinking level. + */ + +import { createAgentSession, findModel, discoverAvailableModels } from "../../src/index.js"; + +// Option 1: Find a specific model by provider/id +const { model: sonnet } = findModel("anthropic", "claude-sonnet-4-20250514"); +if (sonnet) { + console.log(`Found model: ${sonnet.provider}/${sonnet.id}`); +} + +// Option 2: Pick from available models (have valid API keys) +const available = await discoverAvailableModels(); +console.log( + "Available models:", + available.map((m) => `${m.provider}/${m.id}`), +); + +if (available.length > 0) { + const { session } = await createAgentSession({ + model: available[0], + thinkingLevel: "medium", // off, low, medium, high + }); + + session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); + + await session.prompt("Say hello in one sentence."); + console.log(); +} diff --git a/packages/coding-agent/examples/sdk/03-custom-prompt.ts b/packages/coding-agent/examples/sdk/03-custom-prompt.ts new file mode 100644 index 00000000..9f19d67c --- /dev/null +++ b/packages/coding-agent/examples/sdk/03-custom-prompt.ts @@ -0,0 +1,44 @@ +/** + * Custom System Prompt + * + * Shows how to replace or modify the default system prompt. + */ + +import { createAgentSession, SessionManager } from "../../src/index.js"; + +// Option 1: Replace prompt entirely +const { session: session1 } = await createAgentSession({ + systemPrompt: `You are a helpful assistant that speaks like a pirate. +Always end responses with "Arrr!"`, + sessionManager: SessionManager.inMemory(), +}); + +session1.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +console.log("=== Replace prompt ==="); +await session1.prompt("What is 2 + 2?"); +console.log("\n"); + +// Option 2: Modify default prompt (receives default, returns modified) +const { session: session2 } = await createAgentSession({ + systemPrompt: (defaultPrompt) => `${defaultPrompt} + +## Additional Instructions +- Always be concise +- Use bullet points when listing things`, + sessionManager: SessionManager.inMemory(), +}); + +session2.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +console.log("=== Modify prompt ==="); +await session2.prompt("List 3 benefits of TypeScript."); +console.log(); diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts new file mode 100644 index 00000000..f4a3babd --- /dev/null +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -0,0 +1,44 @@ +/** + * Skills Configuration + * + * Skills provide specialized instructions loaded into the system prompt. + * Discover, filter, merge, or replace them. + */ + +import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js"; + +// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc. +const allSkills = discoverSkills(); +console.log( + "Discovered skills:", + allSkills.map((s) => s.name), +); + +// Filter to specific skills +const filteredSkills = allSkills.filter((s) => s.name.includes("browser") || s.name.includes("search")); + +// Or define custom skills inline +const customSkill: Skill = { + name: "my-skill", + description: "Custom project instructions", + filePath: "/virtual/SKILL.md", + baseDir: "/virtual", + source: "custom", +}; + +// Use filtered + custom skills +const { session } = await createAgentSession({ + skills: [...filteredSkills, customSkill], + sessionManager: SessionManager.inMemory(), +}); + +console.log(`Session created with ${filteredSkills.length + 1} skills`); + +// To disable all skills: +// skills: [] + +// To use discovery with filtering via settings: +// discoverSkills(process.cwd(), undefined, { +// ignoredSkills: ["browser-tools"], // glob patterns to exclude +// includeSkills: ["brave-*"], // glob patterns to include (empty = all) +// }) diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts new file mode 100644 index 00000000..a2547847 --- /dev/null +++ b/packages/coding-agent/examples/sdk/05-tools.ts @@ -0,0 +1,67 @@ +/** + * Tools Configuration + * + * Use built-in tool sets, individual tools, or add custom tools. + */ + +import { Type } from "@sinclair/typebox"; +import { + createAgentSession, + discoverCustomTools, + SessionManager, + codingTools, // read, bash, edit, write (default) + readOnlyTools, // read, bash + readTool, + bashTool, + grepTool, + type CustomAgentTool, +} from "../../src/index.js"; + +// Read-only mode (no edit/write) +const { session: readOnly } = await createAgentSession({ + tools: readOnlyTools, + sessionManager: SessionManager.inMemory(), +}); +console.log("Read-only session created"); + +// Custom tool selection +const { session: custom } = await createAgentSession({ + tools: [readTool, bashTool, grepTool], + sessionManager: SessionManager.inMemory(), +}); +console.log("Custom tools session created"); + +// Inline custom tool (needs TypeBox schema) +const weatherTool: CustomAgentTool = { + name: "get_weather", + label: "Get Weather", + description: "Get current weather for a city", + parameters: Type.Object({ + city: Type.String({ description: "City name" }), + }), + execute: async (_toolCallId, params) => ({ + content: [{ type: "text", text: `Weather in ${(params as { city: string }).city}: 22°C, sunny` }], + details: {}, + }), +}; + +const { session } = await createAgentSession({ + customTools: [{ tool: weatherTool }], + sessionManager: SessionManager.inMemory(), +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("What's the weather in Tokyo?"); +console.log(); + +// Merge with discovered tools from cwd/.pi/tools and ~/.pi/agent/tools: +// const discovered = await discoverCustomTools(); +// customTools: [...discovered, { tool: myTool }] + +// Or add paths without replacing discovery: +// additionalCustomToolPaths: ["/extra/tools"] diff --git a/packages/coding-agent/examples/sdk/06-hooks.ts b/packages/coding-agent/examples/sdk/06-hooks.ts new file mode 100644 index 00000000..3519231f --- /dev/null +++ b/packages/coding-agent/examples/sdk/06-hooks.ts @@ -0,0 +1,61 @@ +/** + * Hooks Configuration + * + * Hooks intercept agent events for logging, blocking, or modification. + */ + +import { createAgentSession, discoverHooks, SessionManager, type HookFactory } from "../../src/index.js"; + +// Logging hook +const loggingHook: HookFactory = (api) => { + api.on("agent_start", async () => { + console.log("[Hook] Agent starting"); + }); + + api.on("tool_call", async (event) => { + console.log(`[Hook] Tool: ${event.toolName}`); + return undefined; // Don't block + }); + + api.on("agent_end", async (event) => { + console.log(`[Hook] Done, ${event.messages.length} messages`); + }); +}; + +// Blocking hook (returns { block: true, reason: "..." }) +const safetyHook: HookFactory = (api) => { + api.on("tool_call", async (event) => { + if (event.toolName === "bash") { + const cmd = (event.input as { command?: string }).command ?? ""; + if (cmd.includes("rm -rf")) { + return { block: true, reason: "Dangerous command blocked" }; + } + } + return undefined; + }); +}; + +// Use inline hooks +const { session } = await createAgentSession({ + hooks: [{ factory: loggingHook }, { factory: safetyHook }], + sessionManager: SessionManager.inMemory(), +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("List files in the current directory."); +console.log(); + +// Disable all hooks: +// hooks: [] + +// Merge with discovered hooks: +// const discovered = await discoverHooks(); +// hooks: [...discovered, { factory: myHook }] + +// Add paths without replacing discovery: +// additionalHookPaths: ["/extra/hooks"] diff --git a/packages/coding-agent/examples/sdk/07-context-files.ts b/packages/coding-agent/examples/sdk/07-context-files.ts new file mode 100644 index 00000000..424bb786 --- /dev/null +++ b/packages/coding-agent/examples/sdk/07-context-files.ts @@ -0,0 +1,36 @@ +/** + * Context Files (AGENTS.md) + * + * Context files provide project-specific instructions loaded into the system prompt. + */ + +import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js"; + +// Discover AGENTS.md files walking up from cwd +const discovered = discoverContextFiles(); +console.log("Discovered context files:"); +for (const file of discovered) { + console.log(` - ${file.path} (${file.content.length} chars)`); +} + +// Use custom context files +const { session } = await createAgentSession({ + contextFiles: [ + ...discovered, + { + path: "/virtual/AGENTS.md", + content: `# Project Guidelines + +## Code Style +- Use TypeScript strict mode +- No any types +- Prefer const over let`, + }, + ], + sessionManager: SessionManager.inMemory(), +}); + +console.log(`Session created with ${discovered.length + 1} context files`); + +// Disable context files: +// contextFiles: [] diff --git a/packages/coding-agent/examples/sdk/08-slash-commands.ts b/packages/coding-agent/examples/sdk/08-slash-commands.ts new file mode 100644 index 00000000..188fbd44 --- /dev/null +++ b/packages/coding-agent/examples/sdk/08-slash-commands.ts @@ -0,0 +1,37 @@ +/** + * Slash Commands + * + * File-based commands that inject content when invoked with /commandname. + */ + +import { createAgentSession, discoverSlashCommands, SessionManager, type FileSlashCommand } from "../../src/index.js"; + +// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/ +const discovered = discoverSlashCommands(); +console.log("Discovered slash commands:"); +for (const cmd of discovered) { + console.log(` /${cmd.name}: ${cmd.description}`); +} + +// Define custom commands +const deployCommand: FileSlashCommand = { + name: "deploy", + description: "Deploy the application", + source: "(custom)", + content: `# Deploy Instructions + +1. Build: npm run build +2. Test: npm test +3. Deploy: npm run deploy`, +}; + +// Use discovered + custom commands +const { session } = await createAgentSession({ + slashCommands: [...discovered, deployCommand], + sessionManager: SessionManager.inMemory(), +}); + +console.log(`Session created with ${discovered.length + 1} slash commands`); + +// Disable slash commands: +// slashCommands: [] diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts new file mode 100644 index 00000000..103130f4 --- /dev/null +++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts @@ -0,0 +1,45 @@ +/** + * API Keys and OAuth + * + * Configure API key resolution. Default checks: models.json, OAuth, env vars. + */ + +import { + createAgentSession, + configureOAuthStorage, + defaultGetApiKey, + SessionManager, +} from "../../src/index.js"; +import { getAgentDir } from "../../src/config.js"; + +// Default: uses env vars (ANTHROPIC_API_KEY, etc.), OAuth, and models.json +const { session: defaultSession } = await createAgentSession({ + sessionManager: SessionManager.inMemory(), +}); +console.log("Session with default API key resolution"); + +// Custom resolver +const { session: customSession } = await createAgentSession({ + getApiKey: async (model) => { + // Custom logic (secrets manager, database, etc.) + if (model.provider === "anthropic") { + return process.env.MY_ANTHROPIC_KEY; + } + // Fall back to default + return defaultGetApiKey()(model); + }, + sessionManager: SessionManager.inMemory(), +}); +console.log("Session with custom API key resolver"); + +// Use OAuth from ~/.pi/agent while customizing everything else +configureOAuthStorage(getAgentDir()); // Must call before createAgentSession + +const { session: hybridSession } = await createAgentSession({ + agentDir: "/tmp/custom-config", // Custom config location + // But OAuth tokens still come from ~/.pi/agent/oauth.json + systemPrompt: "You are helpful.", + skills: [], + sessionManager: SessionManager.inMemory(), +}); +console.log("Session with OAuth from default location, custom config elsewhere"); diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts new file mode 100644 index 00000000..c42fab24 --- /dev/null +++ b/packages/coding-agent/examples/sdk/10-settings.ts @@ -0,0 +1,33 @@ +/** + * Settings Configuration + * + * Override settings from agentDir/settings.json. + */ + +import { createAgentSession, loadSettings, SessionManager } from "../../src/index.js"; + +// Load current settings +const settings = loadSettings(); +console.log("Current settings:", JSON.stringify(settings, null, 2)); + +// Override specific settings +const { session } = await createAgentSession({ + settings: { + // Disable auto-compaction + compaction: { enabled: false }, + + // Custom retry behavior + retry: { + enabled: true, + maxRetries: 5, + baseDelayMs: 1000, + }, + + // Terminal options + terminal: { showImages: true }, + hideThinkingBlock: true, + }, + sessionManager: SessionManager.inMemory(), +}); + +console.log("Session created with custom settings"); diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts new file mode 100644 index 00000000..8a7052db --- /dev/null +++ b/packages/coding-agent/examples/sdk/11-sessions.ts @@ -0,0 +1,46 @@ +/** + * Session Management + * + * Control session persistence: in-memory, new file, continue, or open specific. + */ + +import { createAgentSession, SessionManager } from "../../src/index.js"; + +// In-memory (no persistence) +const { session: inMemory } = await createAgentSession({ + sessionManager: SessionManager.inMemory(), +}); +console.log("In-memory session:", inMemory.sessionFile ?? "(none)"); + +// New persistent session +const { session: newSession } = await createAgentSession({ + sessionManager: SessionManager.create(process.cwd()), +}); +console.log("New session file:", newSession.sessionFile); + +// Continue most recent session (or create new if none) +const { session: continued, modelFallbackMessage } = await createAgentSession({ + sessionManager: SessionManager.continueRecent(process.cwd()), +}); +if (modelFallbackMessage) console.log("Note:", modelFallbackMessage); +console.log("Continued session:", continued.sessionFile); + +// List and open specific session +const sessions = SessionManager.list(process.cwd()); +console.log(`\nFound ${sessions.length} sessions:`); +for (const info of sessions.slice(0, 3)) { + console.log(` ${info.id.slice(0, 8)}... - "${info.firstMessage.slice(0, 30)}..."`); +} + +if (sessions.length > 0) { + const { session: opened } = await createAgentSession({ + sessionManager: SessionManager.open(sessions[0].path), + }); + console.log(`\nOpened: ${opened.sessionId}`); +} + +// Custom session directory +// const { session } = await createAgentSession({ +// agentDir: "/custom/agent", +// sessionManager: SessionManager.create(process.cwd(), "/custom/agent"), +// }); diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts new file mode 100644 index 00000000..f2efba49 --- /dev/null +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -0,0 +1,84 @@ +/** + * Full Control + * + * Replace everything - no discovery, explicit configuration. + * Still uses OAuth from ~/.pi/agent for convenience. + */ + +import { Type } from "@sinclair/typebox"; +import { + createAgentSession, + configureOAuthStorage, + defaultGetApiKey, + findModel, + SessionManager, + readTool, + bashTool, + type HookFactory, + type CustomAgentTool, +} from "../../src/index.js"; +import { getAgentDir } from "../../src/config.js"; + +// Use OAuth from default location +configureOAuthStorage(getAgentDir()); + +// Custom API key with fallback +const getApiKey = async (model: { provider: string }) => { + if (model.provider === "anthropic" && process.env.MY_ANTHROPIC_KEY) { + return process.env.MY_ANTHROPIC_KEY; + } + return defaultGetApiKey()(model as any); +}; + +// Inline hook +const auditHook: HookFactory = (api) => { + api.on("tool_call", async (event) => { + console.log(`[Audit] ${event.toolName}`); + return undefined; + }); +}; + +// Inline custom tool +const statusTool: CustomAgentTool = { + name: "status", + label: "Status", + description: "Get system status", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: `Uptime: ${process.uptime()}s, Node: ${process.version}` }], + details: {}, + }), +}; + +const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); +if (!model) throw new Error("Model not found"); + +const { session } = await createAgentSession({ + cwd: process.cwd(), + agentDir: "/tmp/my-agent", + + model, + thinkingLevel: "off", + getApiKey, + + systemPrompt: `You are a minimal assistant. +Available: read, bash, status. Be concise.`, + + tools: [readTool, bashTool], + customTools: [{ tool: statusTool }], + hooks: [{ factory: auditHook }], + skills: [], + contextFiles: [], + slashCommands: [], + sessionManager: SessionManager.inMemory(), + settings: { compaction: { enabled: false } }, +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("Get status and list files."); +console.log(); diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md new file mode 100644 index 00000000..501b8d3f --- /dev/null +++ b/packages/coding-agent/examples/sdk/README.md @@ -0,0 +1,138 @@ +# SDK Examples + +Programmatic usage of pi-coding-agent via `createAgentSession()`. + +## Examples + +| File | Description | +|------|-------------| +| `01-minimal.ts` | Simplest usage with all defaults | +| `02-custom-model.ts` | Select model and thinking level | +| `03-custom-prompt.ts` | Replace or modify system prompt | +| `04-skills.ts` | Discover, filter, or replace skills | +| `05-tools.ts` | Built-in tools, custom tools | +| `06-hooks.ts` | Logging, blocking, result modification | +| `07-context-files.ts` | AGENTS.md context files | +| `08-slash-commands.ts` | File-based slash commands | +| `09-api-keys-and-oauth.ts` | API key resolution, OAuth config | +| `10-settings.ts` | Override compaction, retry, terminal settings | +| `11-sessions.ts` | In-memory, persistent, continue, list sessions | +| `12-full-control.ts` | Replace everything, no discovery | + +## Running + +```bash +cd packages/coding-agent +npx tsx examples/sdk/01-minimal.ts +``` + +## Quick Reference + +```typescript +import { + createAgentSession, + configureOAuthStorage, + discoverSkills, + discoverHooks, + discoverCustomTools, + discoverContextFiles, + discoverSlashCommands, + discoverAvailableModels, + findModel, + defaultGetApiKey, + loadSettings, + buildSystemPrompt, + SessionManager, + codingTools, + readOnlyTools, + readTool, bashTool, editTool, writeTool, +} from "@mariozechner/pi-coding-agent"; + +// Minimal +const { session } = await createAgentSession(); + +// Custom model +const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); +const { session } = await createAgentSession({ model, thinkingLevel: "high" }); + +// Modify prompt +const { session } = await createAgentSession({ + systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.", +}); + +// Read-only +const { session } = await createAgentSession({ tools: readOnlyTools }); + +// In-memory +const { session } = await createAgentSession({ + sessionManager: SessionManager.inMemory(), +}); + +// Full control +configureOAuthStorage(); // Use OAuth from ~/.pi/agent +const { session } = await createAgentSession({ + model, + getApiKey: async (m) => process.env.MY_KEY, + systemPrompt: "You are helpful.", + tools: [readTool, bashTool], + customTools: [{ tool: myTool }], + hooks: [{ factory: myHook }], + skills: [], + contextFiles: [], + slashCommands: [], + sessionManager: SessionManager.inMemory(), + settings: { compaction: { enabled: false } }, +}); + +// Run prompts +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); +await session.prompt("Hello"); +``` + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `cwd` | `process.cwd()` | Working directory | +| `agentDir` | `~/.pi/agent` | Config directory | +| `model` | From settings/first available | Model to use | +| `thinkingLevel` | From settings/"off" | off, low, medium, high | +| `getApiKey` | Built-in resolver | API key function | +| `systemPrompt` | Discovered | String or `(default) => modified` | +| `tools` | `codingTools` | Built-in tools | +| `customTools` | Discovered | Replaces discovery | +| `additionalCustomToolPaths` | `[]` | Merge with discovery | +| `hooks` | Discovered | Replaces discovery | +| `additionalHookPaths` | `[]` | Merge with discovery | +| `skills` | Discovered | Skills for prompt | +| `contextFiles` | Discovered | AGENTS.md files | +| `slashCommands` | Discovered | File commands | +| `sessionManager` | `SessionManager.create(cwd)` | Persistence | +| `settings` | From agentDir | Overrides | + +## Events + +```typescript +session.subscribe((event) => { + switch (event.type) { + case "message_update": + if (event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + break; + case "tool_execution_start": + console.log(`Tool: ${event.toolName}`); + break; + case "tool_execution_end": + console.log(`Result: ${event.result}`); + break; + case "agent_end": + console.log("Done"); + break; + } +}); +``` diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 93056daa..42a8e611 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -103,6 +103,7 @@ export { discoverModels, discoverSkills, discoverSlashCommands, + type FileSlashCommand, findModel as findModelByProviderAndId, loadSettings, // Tools From 05e1f31feb858a9f1d634795d52f2e691866f045 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 03:20:09 +0100 Subject: [PATCH 18/25] Add SDK documentation - docs/sdk.md: comprehensive SDK reference - Core concepts (createAgentSession, AgentSession, Agent, events) - All options with examples - Discovery functions - Complete example - README.md: add SDK section with brief overview and links --- packages/coding-agent/README.md | 42 +- packages/coding-agent/docs/sdk.md | 753 ++++++++++++++++++++++++++++++ 2 files changed, 791 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/docs/sdk.md diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index c5910120..6bffbdf9 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -33,6 +33,9 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows- - [CLI Reference](#cli-reference) - [Tools](#tools) - [Programmatic Usage](#programmatic-usage) + - [SDK](#sdk) + - [RPC Mode](#rpc-mode) + - [HTML Export](#html-export) - [Philosophy](#philosophy) - [Development](#development) - [License](#license) @@ -818,9 +821,42 @@ For adding new tools, see [Custom Tools](#custom-tools) in the Configuration sec ## Programmatic Usage +### SDK + +For embedding pi in Node.js/TypeScript applications, use the SDK: + +```typescript +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; + +const { session } = await createAgentSession({ + sessionManager: SessionManager.inMemory(), +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("What files are in the current directory?"); +``` + +The SDK provides full control over: +- Model selection and thinking level +- System prompt (replace or modify) +- Tools (built-in subsets, custom tools) +- Hooks (inline or discovered) +- Skills, context files, slash commands +- Session persistence +- API key resolution and OAuth + +**Philosophy:** "Omit to discover, provide to override." Omit an option and pi discovers from standard locations. Provide an option and your value is used. + +> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control. + ### RPC Mode -For embedding pi in other applications: +For embedding pi from other languages or with process isolation: ```bash pi --mode rpc --no-session @@ -832,9 +868,7 @@ Send JSON commands on stdin: {"type":"abort"} ``` -See [RPC documentation](docs/rpc.md) for full protocol. - -**Node.js/TypeScript:** Consider using `AgentSession` directly from `@mariozechner/pi-coding-agent` instead of subprocess. See [`src/core/agent-session.ts`](src/core/agent-session.ts) and [`src/modes/rpc/rpc-client.ts`](src/modes/rpc/rpc-client.ts). +> See [RPC Documentation](docs/rpc.md) for the full protocol. ### HTML Export diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md new file mode 100644 index 00000000..fa37a154 --- /dev/null +++ b/packages/coding-agent/docs/sdk.md @@ -0,0 +1,753 @@ +# SDK + +The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows. + +**Example use cases:** +- Build a custom UI (web, desktop, mobile) +- Integrate agent capabilities into existing applications +- Create automated pipelines with agent reasoning +- Build custom tools that spawn sub-agents +- Test agent behavior programmatically + +See [examples/sdk/](../examples/sdk/) for working examples from minimal to full control. + +## Quick Start + +```typescript +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; + +const { session } = await createAgentSession({ + sessionManager: SessionManager.inMemory(), +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("What files are in the current directory?"); +``` + +## Installation + +```bash +npm install @mariozechner/pi-coding-agent +``` + +The SDK is included in the main package. No separate installation needed. + +## Core Concepts + +### createAgentSession() + +The main factory function. Creates an `AgentSession` with configurable options. + +**Philosophy:** "Omit to discover, provide to override." +- Omit an option → pi discovers/loads from standard locations +- Provide an option → your value is used, discovery skipped for that option + +```typescript +import { createAgentSession } from "@mariozechner/pi-coding-agent"; + +// Minimal: all defaults (discovers everything from cwd and ~/.pi/agent) +const { session } = await createAgentSession(); + +// Custom: override specific options +const { session } = await createAgentSession({ + model: myModel, + systemPrompt: "You are helpful.", + tools: [readTool, bashTool], + sessionManager: SessionManager.inMemory(), +}); +``` + +### AgentSession + +The session manages the agent lifecycle, message history, and event streaming. + +```typescript +interface AgentSession { + // Send a prompt and wait for completion + prompt(text: string, options?: PromptOptions): Promise; + + // Subscribe to events (returns unsubscribe function) + subscribe(listener: (event: AgentSessionEvent) => void): () => void; + + // Session info + sessionFile: string | null; + sessionId: string; + + // Model control + setModel(model: Model, thinkingLevel?: ThinkingLevel): void; + setThinkingLevel(level: ThinkingLevel): void; + + // Access underlying agent + agent: Agent; + + // Session management + reset(): void; + branch(targetTurnIndex: number): Promise; + + // Abort current operation + abort(): void; +} +``` + +### Agent and AgentState + +The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM interaction. Access it via `session.agent`. + +```typescript +// Access current state +const state = session.agent.state; + +// state.messages: AppMessage[] - conversation history +// state.model: Model - current model +// state.thinkingLevel: ThinkingLevel - current thinking level +// state.systemPrompt: string - system prompt +// state.tools: Tool[] - available tools + +// Replace messages (useful for branching, restoration) +session.agent.replaceMessages(messages); + +// Wait for agent to finish processing +await session.agent.waitForIdle(); +``` + +### Events + +Subscribe to events to receive streaming output and lifecycle notifications. + +```typescript +session.subscribe((event) => { + switch (event.type) { + // Streaming text from assistant + case "message_update": + if (event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + if (event.assistantMessageEvent.type === "thinking_delta") { + // Thinking output (if thinking enabled) + } + break; + + // Tool execution + case "tool_execution_start": + console.log(`Tool: ${event.toolName}`); + break; + case "tool_execution_update": + // Streaming tool output + break; + case "tool_execution_end": + console.log(`Result: ${event.isError ? "error" : "success"}`); + break; + + // Message lifecycle + case "message_start": + // New message starting + break; + case "message_end": + // Message complete + break; + + // Agent lifecycle + case "agent_start": + // Agent started processing prompt + break; + case "agent_end": + // Agent finished (event.messages contains new messages) + break; + + // Turn lifecycle (one LLM response + tool calls) + case "turn_start": + break; + case "turn_end": + // event.message: assistant response + // event.toolResults: tool results from this turn + break; + + // Session events (auto-compaction, retry) + case "auto_compaction_start": + case "auto_compaction_end": + case "auto_retry_start": + case "auto_retry_end": + break; + } +}); +``` + +## Options Reference + +### Directories + +```typescript +const { session } = await createAgentSession({ + // Working directory for project-local discovery + cwd: process.cwd(), // default + + // Global config directory + agentDir: "~/.pi/agent", // default (expands ~) +}); +``` + +`cwd` is used for: +- Project hooks (`.pi/hooks/`) +- Project tools (`.pi/tools/`) +- Project skills (`.pi/skills/`) +- Project commands (`.pi/commands/`) +- Context files (`AGENTS.md` walking up from cwd) +- Session directory naming + +`agentDir` is used for: +- Global hooks (`hooks/`) +- Global tools (`tools/`) +- Global skills (`skills/`) +- Global commands (`commands/`) +- Global context file (`AGENTS.md`) +- Settings (`settings.json`) +- Models (`models.json`) +- OAuth tokens (`oauth.json`) +- Sessions (`sessions/`) + +### Model + +```typescript +import { findModel, discoverAvailableModels } from "@mariozechner/pi-coding-agent"; + +// Find specific model +const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); + +// Or get all models with valid API keys +const available = await discoverAvailableModels(); + +const { session } = await createAgentSession({ + model: model, + thinkingLevel: "medium", // off, low, medium, high + + // Models for cycling (Ctrl+P in interactive mode) + scopedModels: [ + { model: sonnet, thinkingLevel: "high" }, + { model: haiku, thinkingLevel: "off" }, + ], +}); +``` + +If no model is provided: +1. Tries to restore from session (if continuing) +2. Uses default from settings +3. Falls back to first available model + +### API Keys + +```typescript +import { defaultGetApiKey, configureOAuthStorage } from "@mariozechner/pi-coding-agent"; + +// Default: checks models.json, OAuth, environment variables +const { session } = await createAgentSession(); + +// Custom resolver +const { session } = await createAgentSession({ + getApiKey: async (model) => { + // Custom logic (secrets manager, database, etc.) + if (model.provider === "anthropic") { + return process.env.MY_ANTHROPIC_KEY; + } + // Fall back to default + return defaultGetApiKey()(model); + }, +}); + +// Use OAuth from ~/.pi/agent with custom agentDir for everything else +configureOAuthStorage(); // Must call before createAgentSession +const { session } = await createAgentSession({ + agentDir: "/custom/config", + // OAuth tokens still come from ~/.pi/agent/oauth.json +}); +``` + +### System Prompt + +```typescript +const { session } = await createAgentSession({ + // Replace entirely + systemPrompt: "You are a helpful assistant.", + + // Or modify default (receives default, returns modified) + systemPrompt: (defaultPrompt) => { + return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`; + }, +}); +``` + +### Tools + +```typescript +import { + codingTools, // read, bash, edit, write (default) + readOnlyTools, // read, bash + readTool, bashTool, editTool, writeTool, + grepTool, findTool, lsTool, +} from "@mariozechner/pi-coding-agent"; + +// Use built-in tool set +const { session } = await createAgentSession({ + tools: readOnlyTools, +}); + +// Pick specific tools +const { session } = await createAgentSession({ + tools: [readTool, bashTool, grepTool], +}); +``` + +### Custom Tools + +```typescript +import { Type } from "@sinclair/typebox"; +import { createAgentSession, discoverCustomTools, type CustomAgentTool } from "@mariozechner/pi-coding-agent"; + +// Inline custom tool +const myTool: CustomAgentTool = { + name: "my_tool", + label: "My Tool", + description: "Does something useful", + parameters: Type.Object({ + input: Type.String({ description: "Input value" }), + }), + execute: async (toolCallId, params) => ({ + content: [{ type: "text", text: `Result: ${params.input}` }], + details: {}, + }), +}; + +// Replace discovery with inline tools +const { session } = await createAgentSession({ + customTools: [{ tool: myTool }], +}); + +// Merge with discovered tools +const discovered = await discoverCustomTools(); +const { session } = await createAgentSession({ + customTools: [...discovered, { tool: myTool }], +}); + +// Add paths without replacing discovery +const { session } = await createAgentSession({ + additionalCustomToolPaths: ["/extra/tools"], +}); +``` + +### Hooks + +```typescript +import { createAgentSession, discoverHooks, type HookFactory } from "@mariozechner/pi-coding-agent"; + +// Inline hook +const loggingHook: HookFactory = (api) => { + api.on("tool_call", async (event) => { + console.log(`Tool: ${event.toolName}`); + return undefined; // Don't block + }); + + api.on("tool_call", async (event) => { + // Block dangerous commands + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + return { block: true, reason: "Dangerous command" }; + } + return undefined; + }); +}; + +// Replace discovery +const { session } = await createAgentSession({ + hooks: [{ factory: loggingHook }], +}); + +// Disable all hooks +const { session } = await createAgentSession({ + hooks: [], +}); + +// Merge with discovered +const discovered = await discoverHooks(); +const { session } = await createAgentSession({ + hooks: [...discovered, { factory: loggingHook }], +}); + +// Add paths without replacing +const { session } = await createAgentSession({ + additionalHookPaths: ["/extra/hooks"], +}); +``` + +### Skills + +```typescript +import { createAgentSession, discoverSkills, type Skill } from "@mariozechner/pi-coding-agent"; + +// Discover and filter +const allSkills = discoverSkills(); +const filtered = allSkills.filter(s => s.name.includes("search")); + +// Custom skill +const mySkill: Skill = { + name: "my-skill", + description: "Custom instructions", + filePath: "/path/to/SKILL.md", + baseDir: "/path/to", + source: "custom", +}; + +const { session } = await createAgentSession({ + skills: [...filtered, mySkill], +}); + +// Disable skills +const { session } = await createAgentSession({ + skills: [], +}); + +// Discovery with settings filter +const skills = discoverSkills(process.cwd(), undefined, { + ignoredSkills: ["browser-*"], // glob patterns to exclude + includeSkills: ["search-*"], // glob patterns to include (empty = all) +}); +``` + +### Context Files + +```typescript +import { createAgentSession, discoverContextFiles } from "@mariozechner/pi-coding-agent"; + +// Discover AGENTS.md files +const discovered = discoverContextFiles(); + +// Add custom context +const { session } = await createAgentSession({ + contextFiles: [ + ...discovered, + { + path: "/virtual/AGENTS.md", + content: "# Guidelines\n\n- Be concise\n- Use TypeScript", + }, + ], +}); + +// Disable context files +const { session } = await createAgentSession({ + contextFiles: [], +}); +``` + +### Slash Commands + +```typescript +import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@mariozechner/pi-coding-agent"; + +const discovered = discoverSlashCommands(); + +const customCommand: FileSlashCommand = { + name: "deploy", + description: "Deploy the application", + source: "(custom)", + content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy", +}; + +const { session } = await createAgentSession({ + slashCommands: [...discovered, customCommand], +}); +``` + +### Session Management + +```typescript +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; + +// In-memory (no persistence) +const { session } = await createAgentSession({ + sessionManager: SessionManager.inMemory(), +}); + +// New persistent session +const { session } = await createAgentSession({ + sessionManager: SessionManager.create(process.cwd()), +}); + +// Continue most recent +const { session, modelFallbackMessage } = await createAgentSession({ + sessionManager: SessionManager.continueRecent(process.cwd()), +}); +if (modelFallbackMessage) { + console.log("Note:", modelFallbackMessage); +} + +// Open specific file +const { session } = await createAgentSession({ + sessionManager: SessionManager.open("/path/to/session.jsonl"), +}); + +// List available sessions +const sessions = SessionManager.list(process.cwd()); +for (const info of sessions) { + console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages)`); +} + +// Custom agentDir for sessions +const { session } = await createAgentSession({ + agentDir: "/custom/agent", + sessionManager: SessionManager.create(process.cwd(), "/custom/agent"), +}); +``` + +### Settings + +```typescript +import { createAgentSession, loadSettings } from "@mariozechner/pi-coding-agent"; + +// Load current settings +const settings = loadSettings(); + +// Override specific settings +const { session } = await createAgentSession({ + settings: { + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 }, + terminal: { showImages: true }, + hideThinkingBlock: true, + }, +}); +``` + +## Discovery Functions + +All discovery functions accept optional `cwd` and `agentDir` parameters. + +```typescript +import { + discoverModels, + discoverAvailableModels, + findModel, + discoverSkills, + discoverHooks, + discoverCustomTools, + discoverContextFiles, + discoverSlashCommands, + loadSettings, + buildSystemPrompt, +} from "@mariozechner/pi-coding-agent"; + +// Models +const allModels = discoverModels(); +const available = await discoverAvailableModels(); +const { model, error } = findModel("anthropic", "claude-sonnet-4-20250514"); + +// Skills +const skills = discoverSkills(cwd, agentDir, skillsSettings); + +// Hooks (async - loads TypeScript) +const hooks = await discoverHooks(cwd, agentDir); + +// Custom tools (async - loads TypeScript) +const tools = await discoverCustomTools(cwd, agentDir); + +// Context files +const contextFiles = discoverContextFiles(cwd, agentDir); + +// Slash commands +const commands = discoverSlashCommands(cwd, agentDir); + +// Settings +const settings = loadSettings(agentDir); + +// Build system prompt manually +const prompt = buildSystemPrompt({ + skills, + contextFiles, + appendPrompt: "Additional instructions", + cwd, +}); +``` + +## Return Value + +`createAgentSession()` returns: + +```typescript +interface CreateAgentSessionResult { + // The session + session: AgentSession; + + // Custom tools (for UI setup) + customToolsResult: { + tools: LoadedCustomTool[]; + setUIContext: (ctx, hasUI) => void; + }; + + // Warning if session model couldn't be restored + modelFallbackMessage?: string; +} +``` + +## Complete Example + +```typescript +import { Type } from "@sinclair/typebox"; +import { + createAgentSession, + configureOAuthStorage, + defaultGetApiKey, + findModel, + SessionManager, + readTool, + bashTool, + type HookFactory, + type CustomAgentTool, +} from "@mariozechner/pi-coding-agent"; +import { getAgentDir } from "@mariozechner/pi-coding-agent/config"; + +// Use OAuth from default location +configureOAuthStorage(getAgentDir()); + +// Custom API key with fallback +const getApiKey = async (model: { provider: string }) => { + if (model.provider === "anthropic" && process.env.MY_KEY) { + return process.env.MY_KEY; + } + return defaultGetApiKey()(model as any); +}; + +// Inline hook +const auditHook: HookFactory = (api) => { + api.on("tool_call", async (event) => { + console.log(`[Audit] ${event.toolName}`); + return undefined; + }); +}; + +// Inline tool +const statusTool: CustomAgentTool = { + name: "status", + label: "Status", + description: "Get system status", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: `Uptime: ${process.uptime()}s` }], + details: {}, + }), +}; + +const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); +if (!model) throw new Error("Model not found"); + +const { session } = await createAgentSession({ + cwd: process.cwd(), + agentDir: "/custom/agent", + + model, + thinkingLevel: "off", + getApiKey, + + systemPrompt: "You are a minimal assistant. Be concise.", + + tools: [readTool, bashTool], + customTools: [{ tool: statusTool }], + hooks: [{ factory: auditHook }], + skills: [], + contextFiles: [], + slashCommands: [], + + sessionManager: SessionManager.inMemory(), + + settings: { + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 2 }, + }, +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("Get status and list files."); +``` + +## RPC Mode Alternative + +For subprocess-based integration, use RPC mode instead of the SDK: + +```bash +pi --mode rpc --no-session +``` + +See [RPC documentation](rpc.md) for the JSON protocol. + +The SDK is preferred when: +- You want type safety +- You're in the same Node.js process +- You need direct access to agent state +- You want to customize tools/hooks programmatically + +RPC mode is preferred when: +- You're integrating from another language +- You want process isolation +- You're building a language-agnostic client + +## Exports + +The main entry point exports: + +```typescript +// Factory +createAgentSession +configureOAuthStorage + +// Discovery +discoverModels +discoverAvailableModels +findModel +discoverSkills +discoverHooks +discoverCustomTools +discoverContextFiles +discoverSlashCommands + +// Helpers +defaultGetApiKey +loadSettings +buildSystemPrompt + +// Session management +SessionManager + +// Built-in tools +codingTools +readOnlyTools +readTool, bashTool, editTool, writeTool +grepTool, findTool, lsTool + +// Types +type CreateAgentSessionOptions +type CreateAgentSessionResult +type CustomAgentTool +type HookFactory +type Skill +type FileSlashCommand +type Settings +type SkillsSettings +type Tool +``` + +For hook types, import from the hooks subpath: + +```typescript +import type { HookAPI, HookEvent, ToolCallEvent } from "@mariozechner/pi-coding-agent/hooks"; +``` + +For config utilities: + +```typescript +import { getAgentDir } from "@mariozechner/pi-coding-agent/config"; +``` From 62c64a286ba159bc2d991d17eb367f76439d388e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 12:23:02 +0100 Subject: [PATCH 19/25] Add project-specific settings and SettingsManager factories - SettingsManager now loads .pi/settings.json from cwd (project settings) - Project settings merge with global settings (deep merge for objects) - Setters only modify global settings, project settings are read-only - Add static factories: SettingsManager.create(cwd?, agentDir?), SettingsManager.inMemory(settings?) - Add applyOverrides() for programmatic overrides - Replace 'settings' option with 'settingsManager' in CreateAgentSessionOptions - Update examples to use new pattern Incorporates PR #276 approach --- .../coding-agent/examples/sdk/10-settings.ts | 41 +++-- .../examples/sdk/12-full-control.ts | 9 +- packages/coding-agent/src/core/sdk.ts | 14 +- .../coding-agent/src/core/settings-manager.ts | 159 +++++++++++++----- packages/coding-agent/src/main.ts | 2 +- packages/coding-agent/src/utils/shell.ts | 2 +- .../test/agent-session-branching.test.ts | 2 +- .../test/agent-session-compaction.test.ts | 4 +- 8 files changed, 162 insertions(+), 71 deletions(-) diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts index c42fab24..e91d7e2d 100644 --- a/packages/coding-agent/examples/sdk/10-settings.ts +++ b/packages/coding-agent/examples/sdk/10-settings.ts @@ -1,33 +1,38 @@ /** * Settings Configuration * - * Override settings from agentDir/settings.json. + * Override settings using SettingsManager. */ -import { createAgentSession, loadSettings, SessionManager } from "../../src/index.js"; +import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js"; -// Load current settings +// Load current settings (merged global + project) const settings = loadSettings(); console.log("Current settings:", JSON.stringify(settings, null, 2)); // Override specific settings +const settingsManager = SettingsManager.create(); +settingsManager.applyOverrides({ + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 }, +}); + const { session } = await createAgentSession({ - settings: { - // Disable auto-compaction - compaction: { enabled: false }, - - // Custom retry behavior - retry: { - enabled: true, - maxRetries: 5, - baseDelayMs: 1000, - }, - - // Terminal options - terminal: { showImages: true }, - hideThinkingBlock: true, - }, + settingsManager, sessionManager: SessionManager.inMemory(), }); console.log("Session created with custom settings"); + +// For testing without file I/O: +const inMemorySettings = SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: false }, +}); + +const { session: testSession } = await createAgentSession({ + settingsManager: inMemorySettings, + sessionManager: SessionManager.inMemory(), +}); + +console.log("Test session created with in-memory settings"); diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index f2efba49..4487828b 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -12,6 +12,7 @@ import { defaultGetApiKey, findModel, SessionManager, + SettingsManager, readTool, bashTool, type HookFactory, @@ -53,6 +54,12 @@ const statusTool: CustomAgentTool = { const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); if (!model) throw new Error("Model not found"); +// In-memory settings with overrides +const settingsManager = SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 2 }, +}); + const { session } = await createAgentSession({ cwd: process.cwd(), agentDir: "/tmp/my-agent", @@ -71,7 +78,7 @@ Available: read, bash, status. Be concise.`, contextFiles: [], slashCommands: [], sessionManager: SessionManager.inMemory(), - settings: { compaction: { enabled: false } }, + settingsManager, }); session.subscribe((event) => { diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 0ecf4f24..60e09794 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -111,8 +111,8 @@ export interface CreateAgentSessionOptions { /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; - /** Settings overrides (merged with agentDir/settings.json) */ - settings?: Partial; + /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ + settingsManager?: SettingsManager; } /** Result from createAgentSession */ @@ -338,10 +338,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Settings /** - * Load settings from agentDir/settings.json. + * Load settings from agentDir/settings.json merged with cwd/.pi/settings.json. */ -export function loadSettings(agentDir?: string): Settings { - const manager = new SettingsManager(agentDir ?? getDefaultAgentDir()); +export function loadSettings(cwd?: string, agentDir?: string): Settings { + const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir()); return { defaultProvider: manager.getDefaultProvider(), defaultModel: manager.getDefaultModel(), @@ -449,8 +449,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Configure OAuth storage for this agentDir configureOAuthStorage(agentDir); - const settingsManager = new SettingsManager(agentDir); - const sessionManager = options.sessionManager ?? SessionManager.create(cwd); + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir); // Check if session has existing data to restore const existingSession = sessionManager.loadSession(); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index de4d0d06..e56b2779 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; -import { getAgentDir } from "../config.js"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; export interface CompactionSettings { enabled?: boolean; // default: true @@ -49,39 +49,118 @@ export interface Settings { terminal?: TerminalSettings; } -export class SettingsManager { - private settingsPath: string; - private settings: Settings; +/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + const result: Settings = { ...base }; - constructor(baseDir?: string) { - const dir = baseDir || getAgentDir(); - this.settingsPath = join(dir, "settings.json"); - this.settings = this.load(); + for (const key of Object.keys(overrides) as (keyof Settings)[]) { + const overrideValue = overrides[key]; + const baseValue = base[key]; + + if (overrideValue === undefined) { + continue; + } + + // For nested objects, merge recursively + if ( + typeof overrideValue === "object" && + overrideValue !== null && + !Array.isArray(overrideValue) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) + ) { + (result as Record)[key] = { ...baseValue, ...overrideValue }; + } else { + // For primitives and arrays, override value wins + (result as Record)[key] = overrideValue; + } } - private load(): Settings { - if (!existsSync(this.settingsPath)) { + return result; +} + +export class SettingsManager { + private settingsPath: string | null; + private projectSettingsPath: string | null; + private globalSettings: Settings; + private settings: Settings; + private persist: boolean; + + private constructor( + settingsPath: string | null, + projectSettingsPath: string | null, + initialSettings: Settings, + persist: boolean, + ) { + this.settingsPath = settingsPath; + this.projectSettingsPath = projectSettingsPath; + this.persist = persist; + this.globalSettings = initialSettings; + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } + + /** Create a SettingsManager that loads from files */ + static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager { + const settingsPath = join(agentDir, "settings.json"); + const projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + const globalSettings = SettingsManager.loadFromFile(settingsPath); + return new SettingsManager(settingsPath, projectSettingsPath, globalSettings, true); + } + + /** Create an in-memory SettingsManager (no file I/O) */ + static inMemory(settings: Partial = {}): SettingsManager { + return new SettingsManager(null, null, settings, false); + } + + private static loadFromFile(path: string): Settings { + if (!existsSync(path)) { + return {}; + } + try { + const content = readFileSync(path, "utf-8"); + return JSON.parse(content); + } catch (error) { + console.error(`Warning: Could not read settings file ${path}: ${error}`); + return {}; + } + } + + private loadProjectSettings(): Settings { + if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) { return {}; } try { - const content = readFileSync(this.settingsPath, "utf-8"); + const content = readFileSync(this.projectSettingsPath, "utf-8"); return JSON.parse(content); } catch (error) { - console.error(`Warning: Could not read settings file: ${error}`); + console.error(`Warning: Could not read project settings file: ${error}`); return {}; } } + /** Apply additional overrides on top of current settings */ + applyOverrides(overrides: Partial): void { + this.settings = deepMergeSettings(this.settings, overrides); + } + private save(): void { + if (!this.persist || !this.settingsPath) return; + try { - // Ensure directory exists const dir = dirname(this.settingsPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8"); + // Save only global settings (project settings are read-only) + writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); + + // Re-merge project settings into active settings + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); } catch (error) { console.error(`Warning: Could not save settings file: ${error}`); } @@ -92,7 +171,7 @@ export class SettingsManager { } setLastChangelogVersion(version: string): void { - this.settings.lastChangelogVersion = version; + this.globalSettings.lastChangelogVersion = version; this.save(); } @@ -105,18 +184,18 @@ export class SettingsManager { } setDefaultProvider(provider: string): void { - this.settings.defaultProvider = provider; + this.globalSettings.defaultProvider = provider; this.save(); } setDefaultModel(modelId: string): void { - this.settings.defaultModel = modelId; + this.globalSettings.defaultModel = modelId; this.save(); } setDefaultModelAndProvider(provider: string, modelId: string): void { - this.settings.defaultProvider = provider; - this.settings.defaultModel = modelId; + this.globalSettings.defaultProvider = provider; + this.globalSettings.defaultModel = modelId; this.save(); } @@ -125,7 +204,7 @@ export class SettingsManager { } setQueueMode(mode: "all" | "one-at-a-time"): void { - this.settings.queueMode = mode; + this.globalSettings.queueMode = mode; this.save(); } @@ -134,7 +213,7 @@ export class SettingsManager { } setTheme(theme: string): void { - this.settings.theme = theme; + this.globalSettings.theme = theme; this.save(); } @@ -143,7 +222,7 @@ export class SettingsManager { } setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void { - this.settings.defaultThinkingLevel = level; + this.globalSettings.defaultThinkingLevel = level; this.save(); } @@ -152,10 +231,10 @@ export class SettingsManager { } setCompactionEnabled(enabled: boolean): void { - if (!this.settings.compaction) { - this.settings.compaction = {}; + if (!this.globalSettings.compaction) { + this.globalSettings.compaction = {}; } - this.settings.compaction.enabled = enabled; + this.globalSettings.compaction.enabled = enabled; this.save(); } @@ -180,10 +259,10 @@ export class SettingsManager { } setRetryEnabled(enabled: boolean): void { - if (!this.settings.retry) { - this.settings.retry = {}; + if (!this.globalSettings.retry) { + this.globalSettings.retry = {}; } - this.settings.retry.enabled = enabled; + this.globalSettings.retry.enabled = enabled; this.save(); } @@ -200,7 +279,7 @@ export class SettingsManager { } setHideThinkingBlock(hide: boolean): void { - this.settings.hideThinkingBlock = hide; + this.globalSettings.hideThinkingBlock = hide; this.save(); } @@ -209,7 +288,7 @@ export class SettingsManager { } setShellPath(path: string | undefined): void { - this.settings.shellPath = path; + this.globalSettings.shellPath = path; this.save(); } @@ -218,7 +297,7 @@ export class SettingsManager { } setCollapseChangelog(collapse: boolean): void { - this.settings.collapseChangelog = collapse; + this.globalSettings.collapseChangelog = collapse; this.save(); } @@ -227,7 +306,7 @@ export class SettingsManager { } setHookPaths(paths: string[]): void { - this.settings.hooks = paths; + this.globalSettings.hooks = paths; this.save(); } @@ -236,7 +315,7 @@ export class SettingsManager { } setHookTimeout(timeout: number): void { - this.settings.hookTimeout = timeout; + this.globalSettings.hookTimeout = timeout; this.save(); } @@ -245,7 +324,7 @@ export class SettingsManager { } setCustomToolPaths(paths: string[]): void { - this.settings.customTools = paths; + this.globalSettings.customTools = paths; this.save(); } @@ -254,10 +333,10 @@ export class SettingsManager { } setSkillsEnabled(enabled: boolean): void { - if (!this.settings.skills) { - this.settings.skills = {}; + if (!this.globalSettings.skills) { + this.globalSettings.skills = {}; } - this.settings.skills.enabled = enabled; + this.globalSettings.skills.enabled = enabled; this.save(); } @@ -280,10 +359,10 @@ export class SettingsManager { } setShowImages(show: boolean): void { - if (!this.settings.terminal) { - this.settings.terminal = {}; + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; } - this.settings.terminal.showImages = show; + this.globalSettings.terminal.showImages = show; this.save(); } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5fa89a5a..1ad5616e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -288,7 +288,7 @@ export async function main(args: string[]) { const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; - const settingsManager = new SettingsManager(); + const settingsManager = SettingsManager.create(cwd); initTheme(settingsManager.getTheme(), isInteractive); let scopedModels: ScopedModel[] = []; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index bde51928..d2a1f74f 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -34,7 +34,7 @@ export function getShellConfig(): { shell: string; args: string[] } { return cachedShellConfig; } - const settings = new SettingsManager(); + const settings = SettingsManager.create(); const customShellPath = settings.getShellPath(); // 1. Check user-specified shell path diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index b29299f4..d14fa483 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -57,7 +57,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { }); sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir); - const settingsManager = new SettingsManager(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); session = new AgentSession({ agent, diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index a2574483..7a31053b 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -61,7 +61,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { }); sessionManager = SessionManager.create(tempDir); - const settingsManager = new SettingsManager(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); session = new AgentSession({ agent, @@ -177,7 +177,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { // Create in-memory session manager const noSessionManager = SessionManager.inMemory(); - const settingsManager = new SettingsManager(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); const noSessionSession = new AgentSession({ agent, From 1e6a23ab3d6515f683e1ff75e933060264c71e64 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 12:24:58 +0100 Subject: [PATCH 20/25] Update SDK docs for SettingsManager changes --- packages/coding-agent/docs/sdk.md | 47 +++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index fa37a154..ae840b73 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -500,25 +500,48 @@ const { session } = await createAgentSession({ }); ``` -### Settings +### Settings Management ```typescript -import { createAgentSession, loadSettings } from "@mariozechner/pi-coding-agent"; +import { createAgentSession, SettingsManager, SessionManager } from "@mariozechner/pi-coding-agent"; -// Load current settings -const settings = loadSettings(); - -// Override specific settings +// Default: loads from files (global + project merged) const { session } = await createAgentSession({ - settings: { - compaction: { enabled: false }, - retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 }, - terminal: { showImages: true }, - hideThinkingBlock: true, - }, + settingsManager: SettingsManager.create(), +}); + +// With overrides +const settingsManager = SettingsManager.create(); +settingsManager.applyOverrides({ + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 5 }, +}); +const { session } = await createAgentSession({ settingsManager }); + +// In-memory (no file I/O, for testing) +const { session } = await createAgentSession({ + settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }), + sessionManager: SessionManager.inMemory(), +}); + +// Custom directories +const { session } = await createAgentSession({ + settingsManager: SettingsManager.create("/custom/cwd", "/custom/agent"), }); ``` +**Static factories:** +- `SettingsManager.create(cwd?, agentDir?)` - Load from files +- `SettingsManager.inMemory(settings?)` - No file I/O + +**Project-specific settings:** + +Settings load from two locations and merge: +1. Global: `~/.pi/agent/settings.json` +2. Project: `/.pi/settings.json` + +Project overrides global. Nested objects merge keys. Setters only modify global (project is read-only for version control). + ## Discovery Functions All discovery functions accept optional `cwd` and `agentDir` parameters. From 5d290f048edf86770f72cdc8ede0f68e40169c51 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 12:32:25 +0100 Subject: [PATCH 21/25] Fix SDK docs: AgentSession interface, findModel usage, settings example --- packages/coding-agent/docs/sdk.md | 53 +++++++++++++++++++++---------- packages/coding-agent/src/main.ts | 3 +- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index ae840b73..0d35d54f 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -79,18 +79,32 @@ interface AgentSession { sessionId: string; // Model control - setModel(model: Model, thinkingLevel?: ThinkingLevel): void; + setModel(model: Model): Promise; setThinkingLevel(level: ThinkingLevel): void; + cycleModel(): Promise; + cycleThinkingLevel(): ThinkingLevel | null; - // Access underlying agent + // State access agent: Agent; + model: Model | null; + thinkingLevel: ThinkingLevel; + messages: AppMessage[]; + isStreaming: boolean; // Session management - reset(): void; - branch(targetTurnIndex: number): Promise; + reset(): Promise; + branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }>; + switchSession(sessionPath: string): Promise; + + // Compaction + compact(customInstructions?: string): Promise; + abortCompaction(): void; // Abort current operation - abort(): void; + abort(): Promise; + + // Cleanup + dispose(): void; } ``` @@ -215,15 +229,17 @@ const { session } = await createAgentSession({ ```typescript import { findModel, discoverAvailableModels } from "@mariozechner/pi-coding-agent"; -// Find specific model -const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); +// Find specific model (returns { model, error }) +const { model, error } = findModel("anthropic", "claude-sonnet-4-20250514"); +if (error) throw new Error(error); +if (!model) throw new Error("Model not found"); // Or get all models with valid API keys const available = await discoverAvailableModels(); const { session } = await createAgentSession({ model: model, - thinkingLevel: "medium", // off, low, medium, high + thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh // Models for cycling (Ctrl+P in interactive mode) scopedModels: [ @@ -580,8 +596,8 @@ const contextFiles = discoverContextFiles(cwd, agentDir); // Slash commands const commands = discoverSlashCommands(cwd, agentDir); -// Settings -const settings = loadSettings(agentDir); +// Settings (global + project merged) +const settings = loadSettings(cwd, agentDir); // Build system prompt manually const prompt = buildSystemPrompt({ @@ -622,6 +638,7 @@ import { defaultGetApiKey, findModel, SessionManager, + SettingsManager, readTool, bashTool, type HookFactory, @@ -660,9 +677,16 @@ const statusTool: CustomAgentTool = { }), }; -const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); +const { model, error } = findModel("anthropic", "claude-sonnet-4-20250514"); +if (error) throw new Error(error); if (!model) throw new Error("Model not found"); +// In-memory settings with overrides +const settingsManager = SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 2 }, +}); + const { session } = await createAgentSession({ cwd: process.cwd(), agentDir: "/custom/agent", @@ -681,11 +705,7 @@ const { session } = await createAgentSession({ slashCommands: [], sessionManager: SessionManager.inMemory(), - - settings: { - compaction: { enabled: false }, - retry: { enabled: true, maxRetries: 2 }, - }, + settingsManager, }); session.subscribe((event) => { @@ -744,6 +764,7 @@ buildSystemPrompt // Session management SessionManager +SettingsManager // Built-in tools codingTools diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 1ad5616e..0987d3f6 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -17,6 +17,7 @@ import { getModelsPath, VERSION } from "./config.js"; import type { AgentSession } from "./core/agent-session.js"; import type { LoadedCustomTool } from "./core/custom-tools/index.js"; import { exportFromFile } from "./core/export-html.js"; +import type { HookUIContext } from "./core/index.js"; import { findModel } from "./core/model-config.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { type CreateAgentSessionOptions, configureOAuthStorage, createAgentSession } from "./core/sdk.js"; @@ -54,7 +55,7 @@ async function runInteractiveMode( versionCheckPromise: Promise, initialMessages: string[], customTools: LoadedCustomTool[], - setToolUIContext: (uiContext: import("./core/hooks/types.js").HookUIContext, hasUI: boolean) => void, + setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void, initialMessage?: string, initialAttachments?: Attachment[], fdPath: string | null = null, From e5f74a1ad912da81872dec287118caba00169a1a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 12:34:08 +0100 Subject: [PATCH 22/25] Update README: document project-specific settings, add SettingsManager to SDK list --- packages/coding-agent/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 6bffbdf9..6f306aaf 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -652,7 +652,14 @@ export default factory; ### Settings File -`~/.pi/agent/settings.json` stores persistent preferences: +Settings are loaded from two locations and merged: + +1. **Global:** `~/.pi/agent/settings.json` - user preferences +2. **Project:** `/.pi/settings.json` - project-specific overrides (version control friendly) + +Project settings override global settings. For nested objects, individual keys merge. + +Global `~/.pi/agent/settings.json` stores persistent preferences: ```json { @@ -847,7 +854,8 @@ The SDK provides full control over: - Tools (built-in subsets, custom tools) - Hooks (inline or discovered) - Skills, context files, slash commands -- Session persistence +- Session persistence (`SessionManager`) +- Settings (`SettingsManager`) - API key resolution and OAuth **Philosophy:** "Omit to discover, provide to override." Omit an option and pi discovers from standard locations. Provide an option and your value is used. From 519688cb358a5e42252d31f85c05558e570175f6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 12:40:42 +0100 Subject: [PATCH 23/25] README: clarify TUI settings save to global preferences only --- packages/coding-agent/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 6f306aaf..ed093134 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -657,7 +657,7 @@ Settings are loaded from two locations and merged: 1. **Global:** `~/.pi/agent/settings.json` - user preferences 2. **Project:** `/.pi/settings.json` - project-specific overrides (version control friendly) -Project settings override global settings. For nested objects, individual keys merge. +Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only. Global `~/.pi/agent/settings.json` stores persistent preferences: From f835f031eb0dc2f2544e3ac1b12f7eeb8804eddb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 12:44:41 +0100 Subject: [PATCH 24/25] SDK docs: add links to specific examples in each section --- packages/coding-agent/docs/sdk.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 0d35d54f..85543657 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -254,6 +254,8 @@ If no model is provided: 2. Uses default from settings 3. Falls back to first available model +> See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts) + ### API Keys ```typescript @@ -282,6 +284,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts) + ### System Prompt ```typescript @@ -296,6 +300,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts) + ### Tools ```typescript @@ -317,6 +323,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts) + ### Custom Tools ```typescript @@ -354,6 +362,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts) + ### Hooks ```typescript @@ -397,6 +407,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts) + ### Skills ```typescript @@ -431,6 +443,8 @@ const skills = discoverSkills(process.cwd(), undefined, { }); ``` +> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts) + ### Context Files ```typescript @@ -456,6 +470,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts) + ### Slash Commands ```typescript @@ -475,6 +491,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/08-slash-commands.ts](../examples/sdk/08-slash-commands.ts) + ### Session Management ```typescript @@ -516,6 +534,8 @@ const { session } = await createAgentSession({ }); ``` +> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts) + ### Settings Management ```typescript @@ -558,6 +578,8 @@ Settings load from two locations and merge: Project overrides global. Nested objects merge keys. Setters only modify global (project is read-only for version control). +> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts) + ## Discovery Functions All discovery functions accept optional `cwd` and `agentDir` parameters. From 9473659bda778b29a9a5bab50c089c05b5fc21c2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 12:45:49 +0100 Subject: [PATCH 25/25] Add CHANGELOG entries for SDK and settings changes --- packages/coding-agent/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0ed6b38b..e4ca97e6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Added + +- **SDK for programmatic usage**: New `createAgentSession()` factory with full control over model, tools, hooks, skills, session persistence, and settings. Philosophy: "omit to discover, provide to override". Includes 12 examples and comprehensive documentation. ([#272](https://github.com/badlogic/pi-mono/issues/272)) + +- **Project-specific settings**: Settings now load from both `~/.pi/agent/settings.json` (global) and `/.pi/settings.json` (project). Project settings override global with deep merge for nested objects. Project settings are read-only (for version control). ([#276](https://github.com/badlogic/pi-mono/pull/276)) + +- **SettingsManager static factories**: `SettingsManager.create(cwd?, agentDir?)` for file-based settings, `SettingsManager.inMemory(settings?)` for testing. Added `applyOverrides()` for programmatic overrides. + +- **SessionManager static factories**: `SessionManager.create()`, `SessionManager.open()`, `SessionManager.continueRecent()`, `SessionManager.inMemory()`, `SessionManager.list()` for flexible session management. + ## [0.25.4] - 2025-12-22 ### Fixed