Merge sdk-272: Add SDK for programmatic usage

- createAgentSession() factory with full control
- SessionManager/SettingsManager static factories
- Project-specific settings support
- 12 examples and comprehensive docs

Fixes #272
This commit is contained in:
Mario Zechner 2025-12-22 12:46:32 +01:00
commit 207c2cd566
39 changed files with 3011 additions and 1072 deletions

View file

@ -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 `<cwd>/.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

View file

@ -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)
@ -649,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:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
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:
```json
{
@ -818,9 +828,43 @@ 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 (`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.
> 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 +876,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

View file

@ -0,0 +1,819 @@
# 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<void>;
// Subscribe to events (returns unsubscribe function)
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
// Session info
sessionFile: string | null;
sessionId: string;
// Model control
setModel(model: Model): Promise<void>;
setThinkingLevel(level: ThinkingLevel): void;
cycleModel(): Promise<ModelCycleResult | null>;
cycleThinkingLevel(): ThinkingLevel | null;
// State access
agent: Agent;
model: Model | null;
thinkingLevel: ThinkingLevel;
messages: AppMessage[];
isStreaming: boolean;
// Session management
reset(): Promise<void>;
branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }>;
switchSession(sessionPath: string): Promise<void>;
// Compaction
compact(customInstructions?: string): Promise<CompactionResult>;
abortCompaction(): void;
// Abort current operation
abort(): Promise<void>;
// Cleanup
dispose(): 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 (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, minimal, low, medium, high, xhigh
// 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
> See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)
### 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
});
```
> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)
### 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`;
},
});
```
> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)
### 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],
});
```
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### 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"],
});
```
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### 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"],
});
```
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts)
### 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)
});
```
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
### 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: [],
});
```
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
### 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],
});
```
> See [examples/sdk/08-slash-commands.ts](../examples/sdk/08-slash-commands.ts)
### 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"),
});
```
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts)
### Settings Management
```typescript
import { createAgentSession, SettingsManager, SessionManager } from "@mariozechner/pi-coding-agent";
// Default: loads from files (global + project merged)
const { session } = await createAgentSession({
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: `<cwd>/.pi/settings.json`
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.
```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 (global + project merged)
const settings = loadSettings(cwd, 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,
SettingsManager,
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, 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",
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(),
settingsManager,
});
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
SettingsManager
// 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";
```

View file

@ -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)

View file

@ -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();

View file

@ -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();
}

View file

@ -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();

View file

@ -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)
// })

View file

@ -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"]

View file

@ -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"]

View file

@ -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: []

View file

@ -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: []

View file

@ -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");

View file

@ -0,0 +1,38 @@
/**
* Settings Configuration
*
* Override settings using SettingsManager.
*/
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js";
// 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({
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");

View file

@ -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"),
// });

View file

@ -0,0 +1,91 @@
/**
* 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,
SettingsManager,
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");
// 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",
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(),
settingsManager,
});
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();

View file

@ -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;
}
});
```

View file

@ -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<string | null> {
export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
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;

View file

@ -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 = [];
}

View file

@ -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,11 +311,13 @@ 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 = getAgentDir(),
): Promise<CustomToolsLoadResult> {
const allPaths: string[] = [];
const seen = new Set<string>();
@ -331,8 +333,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(agentDir, "tools");
addPaths(discoverToolsInDir(globalToolsDir));
// 2. Project-local tools: cwd/.pi/tools/

View file

@ -217,15 +217,16 @@ function discoverHooksInDir(dir: string): 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<LoadHooksResult> {
export async function discoverAndLoadHooks(
configuredPaths: string[],
cwd: string,
agentDir: string = getAgentDir(),
): Promise<LoadHooksResult> {
const allPaths: string[] = [];
const seen = new Set<string>();
@ -240,8 +241,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(agentDir, "hooks");
addPaths(discoverHooksInDir(globalHooksDir));
// 2. Project-local hooks: cwd/.pi/hooks/

View file

@ -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<Api>[]; error: string | null } {
const configPath = getModelsPath();
function loadCustomModels(agentDir: string = getAgentDir()): { models: Model<Api>[]; 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<Api>[] {
* Get all models (built-in + custom), freshly loaded
* Returns { models, error } - either models array or error message
*/
export function loadAndMergeModels(): { models: Model<Api>[]; error: string | null } {
export function loadAndMergeModels(agentDir: string = getAgentDir()): { models: Model<Api>[]; error: string | null } {
const builtInModels: Model<Api>[] = [];
const providers = getProviders();
@ -243,7 +244,7 @@ export function loadAndMergeModels(): { models: Model<Api>[]; 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<Api>[]; 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<Api>): Promise<string | undefined> {
// For custom providers, check their apiKey config
@ -357,26 +359,17 @@ export async function getApiKeyForModel(model: Model<Api>): Promise<string | und
* Get only models that have valid API keys available
* Returns { models, error } - either models array or error message
*/
export async function getAvailableModels(): Promise<{ models: Model<Api>[]; error: string | null }> {
const { models: allModels, error } = loadAndMergeModels();
export async function getAvailableModels(
agentDir: string = getAgentDir(),
): Promise<{ models: Model<Api>[]; error: string | null }> {
const { models: allModels, error } = loadAndMergeModels(agentDir);
if (error) {
return { models: [], error };
}
const availableModels: Model<Api>[] = [];
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);
@ -390,8 +383,12 @@ export async function getAvailableModels(): Promise<{ models: Model<Api>[]; 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<Api> | null; error: string | null } {
const { models: allModels, error } = loadAndMergeModels();
export function findModel(
provider: string,
modelId: string,
agentDir: string = getAgentDir(),
): { model: Model<Api> | null; error: string | null } {
const { models: allModels, error } = loadAndMergeModels(agentDir);
if (error) {
return { model: null, error };

View file

@ -0,0 +1,639 @@
/**
* 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, 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";
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 {
/** Working directory for project-local discovery. Default: process.cwd() */
cwd?: string;
/** Global config directory. Default: ~/.pi/agent */
agentDir?: string;
/** Model to use. Default: from settings, else first available */
model?: Model<any>;
/** 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<any>; thinkingLevel: ThinkingLevel }>;
/** API key resolver. Default: defaultGetApiKey() */
getApiKey?: (model: Model<any>) => Promise<string | undefined>;
/** System prompt. String replaces default, function receives default and returns final. */
systemPrompt?: string | ((defaultPrompt: string) => string);
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
tools?: Tool[];
/** Custom tools (replaces discovery). */
customTools?: Array<{ path?: string; tool: CustomAgentTool }>;
/** Additional custom tool paths to load (merged with discovery). */
additionalCustomToolPaths?: string[];
/** Hooks (replaces discovery). */
hooks?: Array<{ path?: string; factory: HookFactory }>;
/** Additional hook paths to load (merged with discovery). */
additionalHookPaths?: string[];
/** 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 manager. Default: SessionManager.create(cwd) */
sessionManager?: SessionManager;
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
settingsManager?: SettingsManager;
}
/** 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
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();
}
/**
* 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).
*/
export function discoverModels(agentDir: string = getDefaultAgentDir()): Model<any>[] {
const { models, error } = loadAndMergeModels(agentDir);
if (error) {
throw new Error(error);
}
return models;
}
/**
* Get models that have valid API keys available.
*/
export async function discoverAvailableModels(agentDir: string = getDefaultAgentDir()): Promise<Model<any>[]> {
const { models, error } = await getAvailableModels(agentDir);
if (error) {
throw new Error(error);
}
return models;
}
/**
* Find a model by provider and ID.
* @returns The model, or null if not found
*/
export function findModel(
provider: string,
modelId: string,
agentDir: string = getDefaultAgentDir(),
): Model<any> | null {
const { model, error } = findModelInternal(provider, modelId, agentDir);
if (error) {
throw new Error(error);
}
return model;
}
/**
* Discover hooks from cwd and agentDir.
*/
export async function discoverHooks(
cwd?: string,
agentDir?: string,
): Promise<Array<{ path: string; factory: HookFactory }>> {
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<Array<{ path: string; tool: CustomAgentTool }>> {
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.
*/
export function defaultGetApiKey(): (model: Model<any>) => Promise<string | undefined> {
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 merged with cwd/.pi/settings.json.
*/
export function loadSettings(cwd?: string, agentDir?: string): Settings {
const manager = SettingsManager.create(cwd ?? process.cwd(), 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<string, Array<(...args: unknown[]) => Promise<unknown>>>();
let sendHandler: (text: string, attachments?: any[]) => void = () => {};
const api = {
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
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 ?? "<inline>",
resolvedPath: def.path ?? "<inline>",
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',
* });
*
* // Continue previous session
* const { session, modelFallbackMessage } = await createAgentSession({
* continueSession: true,
* });
*
* // Full control
* const { session } = await createAgentSession({
* model: myModel,
* getApiKey: async () => process.env.MY_KEY,
* systemPrompt: 'You are helpful.',
* tools: [readTool, bashTool],
* hooks: [],
* skills: [],
* sessionManager: SessionManager.inMemory(),
* });
* ```
*/
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? process.cwd();
const agentDir = options.agentDir ?? getDefaultAgentDir();
// Configure OAuth storage for this agentDir
configureOAuthStorage(agentDir);
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();
const hasExistingSession = existingSession.messages.length > 0;
let model = options.model;
let modelFallbackMessage: string | undefined;
// 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
if (!model) {
const defaultProvider = settingsManager.getDefaultProvider();
const defaultModelId = settingsManager.getDefaultModel();
if (defaultProvider && defaultModelId) {
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];
if (modelFallbackMessage) {
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
}
}
let thinkingLevel = options.thinkingLevel;
// If session has data, restore thinking level from it
if (thinkingLevel === undefined && hasExistingSession) {
thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
}
// Fall back to settings default
if (thinkingLevel === undefined) {
thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off";
}
// Clamp to model capabilities
if (!model.reasoning) {
thinkingLevel = "off";
}
const getApiKey = options.getApiKey ?? defaultGetApiKey();
const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
const builtInTools = options.tools ?? codingTools;
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 ?? "<inline>",
resolvedPath: ct.path ?? "<inline>",
tool: ct.tool,
}));
customToolsResult = {
tools: loadedTools,
setUIContext: () => {},
};
} else {
// 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}`);
}
customToolsResult = result;
}
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, 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}`);
}
if (hooks.length > 0) {
hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout());
}
}
let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)];
if (hookRunner) {
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[];
}
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);
}
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
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;
},
}),
});
// Restore messages if session has existing data
if (hasExistingSession) {
agent.replaceMessages(existingSession.messages);
}
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
scopedModels: options.scopedModels,
fileCommands: slashCommands,
hookRunner,
customTools: customToolsResult.tools,
skillsSettings: settingsManager.getSkillsSettings(),
});
return {
session,
customToolsResult,
modelFallbackMessage,
};
}

View file

@ -1,8 +1,8 @@
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";
import { getAgentDir } from "../config.js";
import { getAgentDir as getDefaultAgentDir } from "../config.js";
function uuidv4(): string {
const bytes = randomBytes(16);
@ -12,18 +12,11 @@ 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;
timestamp: string;
cwd: string;
provider: string;
modelId: string;
thinkingLevel: string;
branchedFrom?: string;
}
@ -50,11 +43,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 +54,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:
<summary>
@ -80,9 +78,6 @@ export const SUMMARY_PREFIX = `The conversation history before this point was co
export const SUMMARY_SUFFIX = `
</summary>`;
/**
* Create a user message containing the summary with the standard prefix.
*/
export function createSummaryMessage(summary: string): AppMessage {
return {
role: "user",
@ -91,9 +86,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 +103,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,22 +113,19 @@ 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;
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 };
}
}
// Find latest compaction event
let latestCompactionIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
@ -156,7 +134,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 +146,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 +154,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,246 +161,103 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
return { messages, thinkingLevel, model };
}
function getSessionDirectory(cwd: string, agentDir: string): string {
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(agentDir, "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 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 pendingEntries: SessionEntry[] = [];
// In-memory entries for --no-session mode (when enabled=false)
private cwd: string;
private persist: boolean;
private flushed: boolean = false;
private inMemoryEntries: SessionEntry[] = [];
constructor(continueSession: boolean = false, customSessionPath?: string) {
this.sessionDir = this.getSessionDirectory();
private constructor(cwd: string, agentDir: string, sessionFile: string | null, persist: boolean) {
this.cwd = cwd;
this.sessionDir = getSessionDirectory(cwd, agentDir);
this.persist = persist;
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
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();
}
if (sessionFile) {
this.setSessionFile(sessionFile);
} else {
this.initNewSession();
this.sessionId = uuidv4();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
this.setSessionFile(sessionFile);
}
}
/** Disable session saving (for --no-session mode) */
disable() {
this.enabled = false;
}
/** Check if session persistence is enabled */
isEnabled(): boolean {
return this.enabled;
}
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 });
}
return sessionDir;
}
private initNewSession(): void {
this.sessionId = uuidv4();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
}
/** 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);
/** 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();
this.flushed = true;
} else {
// Always track in memory
this.sessionId = uuidv4();
this.inMemoryEntries = [];
this.flushed = false;
const entry: SessionHeader = {
type: "session",
id: this.sessionId,
timestamp: new Date().toISOString(),
cwd: this.cwd,
};
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`);
}
}
isPersisted(): boolean {
return this.persist;
}
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;
getCwd(): string {
return this.cwd;
}
getSessionId(): string {
@ -436,70 +268,168 @@ export class SessionManager {
return this.sessionFile;
}
/**
* Load entries directly from the session file (internal helper).
*/
private loadEntriesFromFile(): SessionEntry[] {
if (!existsSync(this.sessionFile)) return [];
reset(): void {
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,
},
];
}
const content = readFileSync(this.sessionFile, "utf8");
const entries: SessionEntry[] = [];
const lines = content.trim().split("\n");
_persist(entry: SessionEntry): void {
if (!this.persist) return;
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as SessionEntry;
entries.push(entry);
} catch {
// Skip malformed lines
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`);
}
}
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);
}
}
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();
if (this.persist) {
for (const entry of newEntries) {
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
}
return newSessionFile;
}
// Otherwise return in-memory entries (for --no-session mode)
return [...this.inMemoryEntries];
this.inMemoryEntries = newEntries;
this.sessionId = newSessionId;
return null;
}
/**
* 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;
}> = [];
/** Create a new session for the given directory */
static create(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager {
return new SessionManager(cwd, agentDir, null, true);
}
/** Open a specific session file */
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, agentDir, path, true);
}
/** Continue the most recent session for the given directory, or create new if none */
static continueRecent(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager {
const sessionDir = getSessionDirectory(cwd, agentDir);
const mostRecent = findMostRecentSession(sessionDir);
if (mostRecent) {
return new SessionManager(cwd, agentDir, mostRecent, true);
}
return new SessionManager(cwd, agentDir, null, true);
}
/** Create an in-memory session (no file persistence) */
static inMemory(): SessionManager {
return new SessionManager(process.cwd(), getDefaultAgentDir(), null, false);
}
/** List all sessions for a directory */
static list(cwd: string, agentDir: string = getDefaultAgentDir()): SessionInfo[] {
const sessionDir = getSessionDirectory(cwd, agentDir);
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 +447,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 +464,6 @@ export class SessionManager {
if (textContent) {
allMessages.push(textContent);
// Get first user message for display
if (!firstMessage && entry.message.role === "user") {
firstMessage = textContent;
}
@ -558,131 +484,16 @@ 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
*/
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
if (this.sessionInitialized) {
this.inMemoryEntries = this.loadEntriesFromFile();
} 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.
*/
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;
}
/**
* 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(),
provider: state.model.provider,
modelId: state.model.id,
thinkingLevel: state.thinkingLevel,
branchedFrom: this.sessionFile,
};
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) {
const messageEntry: SessionMessageEntry = {
type: "message",
timestamp: new Date().toISOString(),
message,
};
appendFileSync(newSessionFile, `${JSON.stringify(messageEntry)}\n`);
}
}
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,
timestamp: new Date().toISOString(),
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;
}
}
}

View file

@ -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<string, unknown>)[key] = { ...baseValue, ...overrideValue };
} else {
// For primitives and arrays, override value wins
(result as Record<string, unknown>)[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<Settings> = {}): 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<Settings>): 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();
}
}

View file

@ -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, "&apos;");
}
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<string, Skill>();
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"));

View file

@ -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;

View file

@ -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<string>();
// 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;
}

View file

@ -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<any>;
// 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 = {

View file

@ -83,6 +83,32 @@ export {
type OAuthPrompt,
type OAuthProvider,
} from "./core/oauth/index.js";
// SDK for programmatic usage
export {
type BuildSystemPromptOptions,
buildSystemPrompt,
type CreateAgentSessionOptions,
type CreateAgentSessionResult,
// Configuration
configureOAuthStorage,
// Factory
createAgentSession,
// Helpers
defaultGetApiKey,
discoverAvailableModels,
discoverContextFiles,
discoverCustomTools,
discoverHooks,
// Discovery
discoverModels,
discoverSkills,
discoverSlashCommands,
type FileSlashCommand,
findModel as findModelByProviderAndId,
loadSettings,
// Tools
readOnlyTools,
} from "./core/sdk.js";
export {
type CompactionEntry,
createSummaryMessage,
@ -93,6 +119,7 @@ export {
parseSessionEntries,
type SessionEntry,
type SessionHeader,
type SessionInfo,
SessionManager,
type SessionMessageEntry,
SUMMARY_PREFIX,

View file

@ -1,61 +1,34 @@
/**
* 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 { setOAuthStorage, supportsXhigh } from "@mariozechner/pi-ai";
import type { Attachment } from "@mariozechner/pi-agent-core";
import { supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname } from "path";
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 { AgentSession } from "./core/agent-session.js";
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./core/custom-tools/index.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 { 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 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";
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 { 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";
import { ensureTool } from "./utils/tools-manager.js";
/** Configure OAuth storage to use the coding-agent's configurable path */
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);
},
});
}
/** Check npm registry for new version (non-blocking) */
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
try {
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
@ -70,46 +43,39 @@ async function checkForNewVersion(currentVersion: string): Promise<string | null
return null;
} catch {
// Silently fail - don't disrupt the user experience
return null;
}
}
/** Run interactive mode with TUI */
async function runInteractiveMode(
session: AgentSession,
version: string,
changelogMarkdown: string | null,
modelFallbackMessage: string | null,
modelFallbackMessage: string | undefined,
versionCheckPromise: Promise<string | null>,
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,
): Promise<void> {
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 +85,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 +94,8 @@ async function runInteractiveMode(
}
}
// Interactive loop
while (true) {
const userInput = await mode.getUserInput();
// Process the message
try {
await session.prompt(userInput);
} catch (error: unknown) {
@ -143,7 +105,6 @@ async function runInteractiveMode(
}
}
/** Prepare initial message from @file arguments */
async function prepareInitialMessage(parsed: Args): Promise<{
initialMessage?: string;
initialAttachments?: Attachment[];
@ -154,11 +115,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,9 +129,123 @@ async function prepareInitialMessage(parsed: Args): Promise<{
};
}
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
if (parsed.continue || parsed.resume) {
return null;
}
const lastVersion = settingsManager.getLastChangelogVersion();
const changelogPath = getChangelogPath();
const entries = parseChangelog(changelogPath);
if (!lastVersion) {
if (entries.length > 0) {
settingsManager.setLastChangelogVersion(VERSION);
return entries.map((e) => e.content).join("\n\n");
}
} else {
const newEntries = getNewEntries(entries, lastVersion);
if (newEntries.length > 0) {
settingsManager.setLastChangelogVersion(VERSION);
return newEntries.map((e) => e.content).join("\n\n");
}
}
return null;
}
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);
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) {
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 = `${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;
}
return options;
}
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);
@ -186,14 +260,12 @@ export async function main(args: string[]) {
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;
@ -207,67 +279,46 @@ export async function main(args: string[]) {
}
}
// 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 cwd = process.cwd();
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
// Determine if we're in interactive mode (needed for theme watcher)
const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text";
// Initialize theme (before any TUI rendering)
const settingsManager = new SettingsManager();
const themeName = settingsManager.getTheme();
initTheme(themeName, isInteractive);
const settingsManager = SettingsManager.create(cwd);
initTheme(settingsManager.getTheme(), 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;
// Create session manager based on CLI flags
let sessionManager = createSessionManager(parsed, cwd);
// 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;
// Handle --resume: show session picker
if (parsed.resume) {
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;
}
sessionManager = SessionManager.open(selectedPath);
}
// Non-interactive mode: fail early if no model available
if (!isInteractive && !initialModel) {
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager);
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
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.");
@ -275,191 +326,25 @@ export async function main(args: string[]) {
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);
// 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);
}
}
// 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 = loadSlashCommands();
// 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) => {
@ -470,7 +355,6 @@ export async function main(args: string[]) {
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(
@ -480,99 +364,18 @@ export async function main(args: string[]) {
modelFallbackMessage,
versionCheckPromise,
parsed.messages,
loadedCustomTools,
setToolUIContext,
customToolsResult.tools,
customToolsResult.setUIContext,
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<void>((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) {
return null;
}
const lastVersion = settingsManager.getLastChangelogVersion();
const changelogPath = getChangelogPath();
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);
return newEntries.map((e) => e.content).join("\n\n");
}
}
return null;
}

View file

@ -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));

View file

@ -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);

View file

@ -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

View file

@ -56,11 +56,8 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
},
});
sessionManager = new SessionManager(false);
if (noSession) {
sessionManager.disable();
}
const settingsManager = new SettingsManager(tempDir);
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
session = new AgentSession({
agent,

View file

@ -60,8 +60,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
},
});
sessionManager = new SessionManager(false);
const settingsManager = new SettingsManager(tempDir);
sessionManager = SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
session = new AgentSession({
agent,
@ -174,11 +174,10 @@ 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);
const settingsManager = SettingsManager.create(tempDir, tempDir);
const noSessionSession = new AgentSession({
agent,

View file

@ -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");
});
});

View file

@ -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

View file

@ -10,14 +10,13 @@
* - 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,
loadSessionFromEntries,
type ModelChangeEntry,
type SessionEntry,
type SessionHeader,
type SessionMessageEntry,
type ThinkingLevelChangeEntry,
} from "@mariozechner/pi-coding-agent";
@ -48,11 +47,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,33 +64,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(initialModel);
}
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(model: { provider: string; id: string; thinkingLevel?: string }): 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,
provider: model.provider,
modelId: model.id,
thinkingLevel: model.thinkingLevel || "off",
};
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`);
}
}
/**
@ -248,47 +246,14 @@ export class MomSessionManager {
return entries;
}
/** Initialize session with header if not already done */
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.channelDir,
provider: state.model?.provider || "unknown",
modelId: state.model?.id || "unknown",
thinkingLevel: state.thinkingLevel,
};
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 {
@ -297,13 +262,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 {
@ -313,18 +273,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 */
@ -349,20 +304,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, "");
@ -370,7 +323,7 @@ export class MomSessionManager {
}
// Compatibility methods for AgentSession
isEnabled(): boolean {
isPersisted(): boolean {
return true;
}