refactor(coding-agent): replace AgentSessionRuntimeHost with closure-based AgentSessionRuntime

- Replace AgentSessionRuntimeHost and bootstrap abstractions with AgentSessionRuntime
- Runtime creation is now closure-based via CreateAgentSessionRuntimeFactory
- Factory closes over process-global fixed inputs, recreates cwd-bound services per effective cwd
- Session config (model, thinking, tools, scoped models) re-resolved per target cwd
- CLI resource paths resolved once at startup as absolute paths
- Swap lifecycle: teardown old, create next, apply next (hard fail on creation error)
- Unified diagnostics model (info/warning/error) for args, services, session resolution, resources
- No logging or process exits inside creation/parsing logic
- Removed session_directory support
- Removed session_switch and session_fork extension events (use session_start with reason)
- Moved package/config CLI to package-manager-cli.ts
- Fixed theme init for --resume session picker
- Fixed flaky reftable footer test (content-based polling)
- Fixed silent drop of unknown single-dash CLI flags
- Added error diagnostics for missing explicit CLI resource paths
- Updated SDK docs, examples, plans, exports, tests, changelog

fixes #2753
This commit is contained in:
Mario Zechner
2026-04-03 20:14:12 +02:00
parent 042066b982
commit 9f9277ccdd
38 changed files with 2180 additions and 1366 deletions

View File

@@ -1554,23 +1554,6 @@ export const MODELS = {
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"claude-3-7-sonnet-latest": {
id: "claude-3-7-sonnet-latest",
name: "Claude Sonnet 3.7 (latest)",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"],
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"claude-3-haiku-20240307": {
id: "claude-3-haiku-20240307",
name: "Claude Haiku 3",
@@ -6480,40 +6463,6 @@ export const MODELS = {
contextWindow: 262144,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"mimo-v2-omni-free": {
id: "mimo-v2-omni-free",
name: "MiMo V2 Omni Free",
api: "openai-completions",
provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"mimo-v2-pro-free": {
id: "mimo-v2-pro-free",
name: "MiMo V2 Pro Free",
api: "openai-completions",
provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"minimax-m2.5": {
id: "minimax-m2.5",
name: "MiniMax M2.5",
@@ -6562,9 +6511,26 @@ export const MODELS = {
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1000000,
contextWindow: 204800,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"qwen3.6-plus-free": {
id: "qwen3.6-plus-free",
name: "Qwen3.6 Plus Free",
api: "openai-completions",
provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
},
"opencode-go": {
"glm-5": {
@@ -6601,12 +6567,46 @@ export const MODELS = {
contextWindow: 262144,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"mimo-v2-omni": {
id: "mimo-v2-omni",
name: "MiMo V2 Omni",
api: "openai-completions",
provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.4,
output: 2,
cacheRead: 0.08,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"mimo-v2-pro": {
id: "mimo-v2-pro",
name: "MiMo V2 Pro",
api: "openai-completions",
provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1",
reasoning: true,
input: ["text"],
cost: {
input: 1,
output: 3,
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"minimax-m2.5": {
id: "minimax-m2.5",
name: "MiniMax M2.5",
api: "anthropic-messages",
api: "openai-completions",
provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go",
baseUrl: "https://opencode.ai/zen/go/v1",
reasoning: true,
input: ["text"],
cost: {
@@ -6617,7 +6617,7 @@ export const MODELS = {
},
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
} satisfies Model<"openai-completions">,
"minimax-m2.7": {
id: "minimax-m2.7",
name: "MiniMax M2.7",
@@ -7011,6 +7011,23 @@ export const MODELS = {
contextWindow: 131000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"arcee-ai/trinity-large-thinking": {
id: "arcee-ai/trinity-large-thinking",
name: "Arcee AI: Trinity Large Thinking",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0.22,
output: 0.85,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"arcee-ai/trinity-mini": {
id: "arcee-ai/trinity-mini",
name: "Arcee AI: Trinity Mini",
@@ -7451,7 +7468,7 @@ export const MODELS = {
cacheWrite: 0.08333333333333334,
},
contextWindow: 1048576,
maxTokens: 65536,
maxTokens: 65535,
} satisfies Model<"openai-completions">,
"google/gemini-2.5-pro": {
id: "google/gemini-2.5-pro",
@@ -7572,6 +7589,23 @@ export const MODELS = {
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"google/gemma-4-31b-it": {
id: "google/gemma-4-31b-it",
name: "Google: Gemma 4 31B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.14,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"inception/mercury": {
id: "inception/mercury",
name: "Inception: Mercury",
@@ -7623,23 +7657,6 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 32000,
} satisfies Model<"openai-completions">,
"kwaipilot/kat-coder-pro": {
id: "kwaipilot/kat-coder-pro",
name: "Kwaipilot: KAT-Coder-Pro V1",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.207,
output: 0.828,
cacheRead: 0.0414,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"kwaipilot/kat-coder-pro-v2": {
id: "kwaipilot/kat-coder-pro-v2",
name: "Kwaipilot: KAT-Coder-Pro V2",
@@ -7853,9 +7870,9 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.19,
output: 1.15,
cacheRead: 0.095,
input: 0.118,
output: 0.9900000000000001,
cacheRead: 0.059,
cacheWrite: 0,
},
contextWindow: 196608,
@@ -8150,23 +8167,6 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-small-24b-instruct-2501": {
id: "mistralai/mistral-small-24b-instruct-2501",
name: "Mistral: Mistral Small 3",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.049999999999999996,
output: 0.08,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mistral-small-2603": {
id: "mistralai/mistral-small-2603",
name: "Mistral: Mistral Small 4",
@@ -9221,6 +9221,40 @@ export const MODELS = {
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"openai/gpt-audio": {
id: "openai/gpt-audio",
name: "OpenAI: GPT Audio",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2.5,
output: 10,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"openai/gpt-audio-mini": {
id: "openai/gpt-audio-mini",
name: "OpenAI: GPT Audio Mini",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.6,
output: 2.4,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"openai/gpt-oss-120b": {
id: "openai/gpt-oss-120b",
name: "OpenAI: gpt-oss-120b",
@@ -10188,7 +10222,7 @@ export const MODELS = {
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 65536,
maxTokens: 32768,
} satisfies Model<"openai-completions">,
"qwen/qwen3.5-flash-02-23": {
id: "qwen/qwen3.5-flash-02-23",
@@ -10224,6 +10258,23 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"qwen/qwen3.6-plus:free": {
id: "qwen/qwen3.6-plus:free",
name: "Qwen: Qwen3.6 Plus (free)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"qwen/qwq-32b": {
id: "qwen/qwq-32b",
name: "Qwen: QwQ 32B",
@@ -10241,8 +10292,8 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"reka/reka-edge": {
id: "reka/reka-edge",
"rekaai/reka-edge": {
id: "rekaai/reka-edge",
name: "Reka Edge",
api: "openai-completions",
provider: "openrouter",
@@ -10320,11 +10371,11 @@ export const MODELS = {
cost: {
input: 0.09999999999999999,
output: 0.3,
cacheRead: 0.02,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 4096,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"stepfun/step-3.5-flash:free": {
id: "stepfun/step-3.5-flash:free",
@@ -10530,9 +10581,9 @@ export const MODELS = {
contextWindow: 2000000,
maxTokens: 30000,
} satisfies Model<"openai-completions">,
"x-ai/grok-4.20-beta": {
id: "x-ai/grok-4.20-beta",
name: "xAI: Grok 4.20 Beta",
"x-ai/grok-4.20": {
id: "x-ai/grok-4.20",
name: "xAI: Grok 4.20",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
@@ -10802,6 +10853,23 @@ export const MODELS = {
contextWindow: 202752,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"z-ai/glm-5v-turbo": {
id: "z-ai/glm-5v-turbo",
name: "Z.ai: GLM 5V Turbo",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 1.2,
output: 4,
cacheRead: 0.24,
cacheWrite: 0,
},
contextWindow: 202752,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
},
"vercel-ai-gateway": {
"alibaba/qwen-3-14b": {
@@ -11059,6 +11127,23 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"alibaba/qwen3.6-plus": {
id: "alibaba/qwen3.6-plus",
name: "Qwen 3.6 Plus",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0.09999999999999999,
cacheWrite: 0.625,
},
contextWindow: 1000000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"anthropic/claude-3-haiku": {
id: "anthropic/claude-3-haiku",
name: "Claude 3 Haiku",
@@ -11297,6 +11382,23 @@ export const MODELS = {
contextWindow: 131000,
maxTokens: 131000,
} satisfies Model<"anthropic-messages">,
"arcee-ai/trinity-large-thinking": {
id: "arcee-ai/trinity-large-thinking",
name: "Trinity Large Thinking",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: {
input: 0.25,
output: 0.8999999999999999,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262100,
maxTokens: 80000,
} satisfies Model<"anthropic-messages">,
"bytedance/seed-1.6": {
id: "bytedance/seed-1.6",
name: "Seed 1.6",
@@ -11586,6 +11688,40 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"google/gemma-4-26b-a4b-it": {
id: "google/gemma-4-26b-a4b-it",
name: "Gemma 4 26B A4B IT",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.13,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"google/gemma-4-31b-it": {
id: "google/gemma-4-31b-it",
name: "Gemma 4 31B IT",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.14,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"inception/mercury-2": {
id: "inception/mercury-2",
name: "Mercury 2",
@@ -12090,7 +12226,7 @@ export const MODELS = {
cost: {
input: 0.6,
output: 2.5,
cacheRead: 0.15,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
@@ -12107,11 +12243,11 @@ export const MODELS = {
cost: {
input: 0.6,
output: 2.5,
cacheRead: 0.15,
cacheRead: 0.3,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 16384,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"moonshotai/kimi-k2-thinking": {
id: "moonshotai/kimi-k2-thinking",
@@ -12700,12 +12836,12 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.07,
output: 0.3,
input: 0.049999999999999996,
output: 0.19999999999999998,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
contextWindow: 131072,
maxTokens: 8192,
} satisfies Model<"anthropic-messages">,
"openai/gpt-oss-safeguard-20b": {
@@ -13388,6 +13524,23 @@ export const MODELS = {
contextWindow: 202800,
maxTokens: 131100,
} satisfies Model<"anthropic-messages">,
"zai/glm-5v-turbo": {
id: "zai/glm-5v-turbo",
name: "GLM 5V Turbo",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 1.2,
output: 4,
cacheRead: 0.24,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
},
"xai": {
"grok-2": {
@@ -13878,7 +14031,7 @@ export const MODELS = {
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text"],
cost: {
@@ -13896,7 +14049,7 @@ export const MODELS = {
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text", "image"],
cost: {
@@ -13914,7 +14067,7 @@ export const MODELS = {
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text"],
cost: {
@@ -13932,7 +14085,7 @@ export const MODELS = {
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text"],
cost: {
@@ -13950,7 +14103,7 @@ export const MODELS = {
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text"],
cost: {
@@ -13968,7 +14121,7 @@ export const MODELS = {
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text"],
cost: {
@@ -13986,7 +14139,7 @@ export const MODELS = {
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text"],
cost: {
@@ -13998,5 +14151,23 @@ export const MODELS = {
contextWindow: 200000,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"glm-5v-turbo": {
id: "glm-5v-turbo",
name: "glm-5v-turbo",
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true},
reasoning: true,
input: ["text", "image"],
cost: {
input: 1.2,
output: 4,
cacheRead: 0.24,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
},
} as const;

View File

@@ -4,74 +4,78 @@
### Breaking Changes
- Removed extension post-transition events `session_switch` and `session_fork`. Extensions should now use `session_start` and inspect `event.reason`, which is now one of `"startup" | "reload" | "new" | "resume" | "fork"`. For `"new"`, `"resume"`, and `"fork"`, `session_start` also includes `previousSessionFile`. This is better because session replacement now fully reloads extensions, so one post-start hook with explicit reason matches the real lifecycle better than two extra non-cancellable post-transition events.
- Removed session-replacement methods from `AgentSession`. Use `AgentSessionRuntimeHost` for `newSession()`, `switchSession()`, `fork()`, and `importFromJsonl()`. This is better because cross-cwd session replacement rebuilds cwd-bound runtime state and can replace the live `AgentSession` instance entirely. Keeping those operations on a stable runtime host matches the real lifecycle and avoids pretending one `AgentSession` mutates into another.
- Removed extension post-transition events `session_switch` and `session_fork`. Use `session_start` with `event.reason` (`"startup" | "reload" | "new" | "resume" | "fork"`). For `"new"`, `"resume"`, and `"fork"`, `session_start` includes `previousSessionFile`.
- Removed session-replacement methods from `AgentSession`. Use `AgentSessionRuntime` for `newSession()`, `switchSession()`, `fork()`, and `importFromJsonl()`. Cross-cwd session replacement rebuilds all cwd-bound runtime state and replaces the live `AgentSession` instance.
- Removed `session_directory` from extension and settings APIs.
- Unknown single-dash CLI flags (e.g. `-s`) now produce an error instead of being silently ignored.
#### Migration Notes
For existing extensions:
#### Migration: Extensions
Before:
```ts
pi.on("session_switch", async (event, ctx) => {
if (event.reason === "new") {
resetState();
}
});
pi.on("session_fork", async (_event, ctx) => {
reconstructState(ctx);
});
pi.on("session_switch", async (event, ctx) => { ... });
pi.on("session_fork", async (_event, ctx) => { ... });
```
After:
```ts
pi.on("session_start", async (event, ctx) => {
if (event.reason === "new") {
resetState();
}
if (event.reason === "fork" || event.reason === "resume" || event.reason === "startup") {
reconstructState(ctx);
}
// event.reason: "startup" | "reload" | "new" | "resume" | "fork"
// event.previousSessionFile: set for "new", "resume", "fork"
});
```
For existing SDK integrations:
#### Migration: SDK session replacement
Before:
```ts
await session.newSession();
await session.switchSession("/path/to/session.jsonl");
await session.fork("entry-id");
await session.importFromJsonl(jsonl);
```
After:
```ts
const runtime = await createAgentSessionRuntime(bootstrap, {
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runtimeHost.newSession();
await runtimeHost.switchSession("/path/to/session.jsonl");
await runtimeHost.fork("entry-id");
await runtimeHost.importFromJsonl(jsonl);
await runtime.newSession();
await runtime.switchSession("/path/to/session.jsonl");
await runtime.fork("entry-id");
const session = runtimeHost.session;
// After replacement, runtime.session is the new live session.
// Rebind any session-local subscriptions or extension bindings.
```
After runtime replacement, use `runtimeHost.session` as the new live session and rebind any session-local subscriptions or extension bindings.
### Added
- Added public SDK runtime-host exports `createAgentSessionRuntime()` and `AgentSessionRuntimeHost` for apps that need runtime-backed session replacement and mode-style session switching
- Added `createAgentSessionRuntime()` and `AgentSessionRuntime` for runtime-backed session replacement. The runtime takes a `CreateAgentSessionRuntimeFactory` closure that closes over process-global fixed inputs and recreates cwd-bound services and session config for each effective cwd. Startup and later `/new`, `/resume`, `/fork`, import all use the same factory.
- Added unified diagnostics model (`info`/`warning`/`error`) for arg parsing, service creation, session option resolution, and resource loading. Creation logic no longer logs or exits. The app layer decides presentation and exit behavior.
- Added error diagnostics for missing explicit CLI resource paths (`-e`, `--skill`, `--prompt-template`, `--theme`)
- Added `defineTool()` so standalone and array-based custom tool definitions keep inferred parameter types without manual casts ([#2746](https://github.com/badlogic/pi-mono/issues/2746))

View File

@@ -405,7 +405,7 @@ const { session } = await createAgentSession({
await session.prompt("What files are in the current directory?");
```
For advanced multi-session runtime replacement, use `createAgentSessionRuntime()` and `AgentSessionRuntimeHost`.
For advanced multi-session runtime replacement, use `createAgentSessionRuntime()` and `AgentSessionRuntime`.
See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/).

View File

@@ -226,9 +226,8 @@ Run `npm install` in the extension directory, then imports from `node_modules/`
### Lifecycle Overview
```
pi starts (CLI only)
pi starts
├─► session_directory (CLI startup only, no ctx)
├─► session_start { reason: "startup" }
└─► resources_discover { reason: "startup" }
@@ -311,27 +310,6 @@ pi.on("resources_discover", async (event, _ctx) => {
See [session.md](session.md) for session storage internals and the SessionManager API.
#### session_directory
Fired by the `pi` CLI during startup session resolution, before the initial session manager is created.
This event is:
- CLI-only. It is not emitted in SDK mode.
- Startup-only. It is not emitted for later interactive `/new` or `/resume` actions.
- Lower priority than `--session-dir` and `sessionDir` in `settings.json`.
- Special-cased to receive no `ctx` argument.
If multiple extensions return `sessionDir`, the last one wins.
Combined precedence is: `--session-dir` CLI flag, then `sessionDir` in settings, then extension `session_directory` hooks.
```typescript
pi.on("session_directory", async (event) => {
return {
sessionDir: `/tmp/pi-sessions/${encodeURIComponent(event.cwd)}`,
};
});
```
#### session_start
Fired when a session is started, loaded, or reloaded.
@@ -759,9 +737,7 @@ Transforms chain across handlers. See [input-transform.ts](../examples/extension
## ExtensionContext
All handlers except `session_directory` receive `ctx: ExtensionContext`.
`session_directory` is a CLI startup hook and receives only the event.
All handlers receive `ctx: ExtensionContext`.
### ctx.ui

View File

@@ -115,33 +115,46 @@ interface AgentSession {
}
```
Session replacement APIs such as new-session, resume, fork, and import live on `AgentSessionRuntimeHost`, not on `AgentSession`.
Session replacement APIs such as new-session, resume, fork, and import live on `AgentSessionRuntime`, not on `AgentSession`.
### createAgentSessionRuntime() and AgentSessionRuntimeHost
### createAgentSessionRuntime() and AgentSessionRuntime
Use the runtime API when you need to replace the active session and rebuild cwd-bound runtime state.
This is the same layer used by the built-in interactive, print, and RPC modes.
`createAgentSessionRuntime()` takes a runtime factory plus the initial cwd/session target. The factory closes over process-global fixed inputs, recreates cwd-bound services for the effective cwd, resolves session options against those services, and returns a full runtime result.
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {
// Optional: authStorage, model, thinkingLevel, tools, customTools, resourceLoader
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
```
`createAgentSessionRuntime()` returns an internal runtime bundle. `AgentSessionRuntimeHost` owns replacement of that bundle across:
`AgentSessionRuntime` owns replacement of the active runtime across:
- `newSession()`
- `switchSession()`
@@ -150,18 +163,20 @@ const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
Important behavior:
- `runtimeHost.session` changes after those operations
- `runtime.session` changes after those operations
- event subscriptions are attached to a specific `AgentSession`, so re-subscribe after replacement
- if you use extensions, call `runtimeHost.session.bindExtensions(...)` again for the new session
- if you use extensions, call `runtime.session.bindExtensions(...)` again for the new session
- creation returns diagnostics on `runtime.diagnostics`
- if runtime creation or replacement fails, the method throws and the caller decides how to handle it
```typescript
let session = runtimeHost.session;
let session = runtime.session;
let unsubscribe = session.subscribe(() => {});
await runtimeHost.newSession();
await runtime.newSession();
unsubscribe();
session = runtimeHost.session;
session = runtime.session;
unsubscribe = session.subscribe(() => {});
```
@@ -646,9 +661,12 @@ Sessions use a tree structure with `id`/`parentId` linking, enabling in-place br
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSession,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@mariozechner/pi-coding-agent";
@@ -680,21 +698,33 @@ const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());
// Session replacement API for /new, /resume, /fork, and import flows.
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
// Replace the active session with a fresh one
await runtimeHost.newSession();
await runtime.newSession();
// Replace the active session with another saved session
await runtimeHost.switchSession("/path/to/session.jsonl");
await runtime.switchSession("/path/to/session.jsonl");
// Replace the active session with a fork from a specific entry
await runtimeHost.fork("entry-id");
await runtime.fork("entry-id");
```
**SessionManager tree API:**
@@ -916,20 +946,30 @@ Full TUI interactive mode with editor, chat history, and all built-in commands:
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
InteractiveMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
const mode = new InteractiveMode(runtimeHost, {
const mode = new InteractiveMode(runtime, {
migratedProviders: [],
modelFallbackMessage: undefined,
initialMessage: "Hello",
@@ -946,20 +986,30 @@ Single-shot mode: send prompts, output result, exit:
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
runPrintMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runPrintMode(runtimeHost, {
await runPrintMode(runtime, {
mode: "text",
initialMessage: "Hello",
initialImages: [],
@@ -973,20 +1023,30 @@ JSON-RPC mode for subprocess integration:
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
runRpcMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runRpcMode(runtimeHost);
await runRpcMode(runtime);
```
See [RPC documentation](rpc.md) for the JSON protocol.
@@ -1020,7 +1080,7 @@ The main entry point exports:
// Factory
createAgentSession
createAgentSessionRuntime
AgentSessionRuntimeHost
AgentSessionRuntime
// Auth and Models
AuthStorage

View File

@@ -137,7 +137,7 @@ When a provider requests a retry delay longer than `maxDelayMs` (e.g., Google's
{ "sessionDir": ".pi/sessions" }
```
When multiple sources specify a session directory, `--session-dir` CLI flag takes precedence, then `sessionDir` in settings.json, then extension hooks.
When multiple sources specify a session directory, `--session-dir` CLI flag takes precedence over `sessionDir` in settings.json.
### Model Cycling

View File

@@ -1,27 +1,45 @@
/**
* Session Runtime Host
* Session runtime
*
* Use the runtime host when you need to replace the active AgentSession,
* Use AgentSessionRuntime when you need to replace the active AgentSession,
* for example for new-session, resume, fork, or import flows.
*
* The important pattern is: after the host replaces the runtime, rebind any
* session-local subscriptions and extension bindings to `runtimeHost.session`.
* The important pattern is: after the runtime replaces the active session,
* rebind any session-local subscriptions and extension bindings to `runtime.session`.
*/
import { AgentSessionRuntimeHost, createAgentSessionRuntime, SessionManager } from "@mariozechner/pi-coding-agent";
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
let unsubscribe: (() => void) | undefined;
async function bindSession() {
unsubscribe?.();
const session = runtimeHost.session;
const session = runtime.session;
await session.bindExtensions({});
unsubscribe = session.subscribe((event) => {
if (event.type === "queue_update") {
@@ -35,15 +53,15 @@ let session = await bindSession();
const originalSessionFile = session.sessionFile;
console.log("Initial session:", originalSessionFile);
await runtimeHost.newSession();
await runtime.newSession();
session = await bindSession();
console.log("After newSession():", session.sessionFile);
if (originalSessionFile) {
await runtimeHost.switchSession(originalSessionFile);
await runtime.switchSession(originalSessionFile);
session = await bindSession();
console.log("After switchSession():", session.sessionFile);
}
unsubscribe?.();
await runtimeHost.dispose();
await runtime.dispose();

View File

@@ -2,6 +2,8 @@
Programmatic usage of pi-coding-agent via `createAgentSession()` and `createAgentSessionRuntime()`.
The runtime example shows how to build a recreate function that closes over process-global fixed inputs and recreates cwd-bound services and sessions as the active session cwd changes.
## Examples
| File | Description |

View File

@@ -5,6 +5,7 @@
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import chalk from "chalk";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR } from "../config.js";
import type { ExtensionFlag } from "../core/extensions/types.js";
import { allTools, type ToolName } from "../core/tools/index.js";
export type Mode = "text" | "json" | "rpc";
@@ -45,6 +46,7 @@ export interface Args {
fileArgs: string[];
/** Unknown flags (potentially extension flags) - map of flag name to value */
unknownFlags: Map<string, boolean | string>;
diagnostics: Array<{ type: "warning" | "error"; message: string }>;
}
const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
@@ -53,11 +55,12 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel {
return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
}
export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
export function parseArgs(args: string[]): Args {
const result: Args = {
messages: [],
fileArgs: [],
unknownFlags: new Map(),
diagnostics: [],
};
for (let i = 0; i < args.length; i++) {
@@ -105,9 +108,10 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
if (name in allTools) {
validTools.push(name as ToolName);
} else {
console.error(
chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`),
);
result.diagnostics.push({
type: "warning",
message: `Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`,
});
}
}
result.tools = validTools;
@@ -116,11 +120,10 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
if (isValidThinkingLevel(level)) {
result.thinking = level;
} else {
console.error(
chalk.yellow(
`Warning: Invalid thinking level "${level}". Valid values: ${VALID_THINKING_LEVELS.join(", ")}`,
),
);
result.diagnostics.push({
type: "warning",
message: `Invalid thinking level "${level}". Valid values: ${VALID_THINKING_LEVELS.join(", ")}`,
});
}
} else if (arg === "--print" || arg === "-p") {
result.print = true;
@@ -159,18 +162,22 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
result.offline = true;
} else if (arg.startsWith("@")) {
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
} else if (arg.startsWith("--") && extensionFlags) {
// Check if it's an extension-registered flag
const flagName = arg.slice(2);
const extFlag = extensionFlags.get(flagName);
if (extFlag) {
if (extFlag.type === "boolean") {
} else if (arg.startsWith("--")) {
const eqIndex = arg.indexOf("=");
if (eqIndex !== -1) {
result.unknownFlags.set(arg.slice(2, eqIndex), arg.slice(eqIndex + 1));
} else {
const flagName = arg.slice(2);
const next = args[i + 1];
if (next !== undefined && !next.startsWith("-") && !next.startsWith("@")) {
result.unknownFlags.set(flagName, next);
i++;
} else {
result.unknownFlags.set(flagName, true);
} else if (extFlag.type === "string" && i + 1 < args.length) {
result.unknownFlags.set(flagName, args[++i]);
}
}
// Unknown flags without extensionFlags are silently ignored (first pass)
} else if (arg.startsWith("-") && !arg.startsWith("--")) {
result.diagnostics.push({ type: "error", message: `Unknown option: ${arg}` });
} else if (!arg.startsWith("-")) {
result.messages.push(arg);
}
@@ -179,7 +186,17 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
return result;
}
export function printHelp(): void {
export function printHelp(extensionFlags?: ExtensionFlag[]): void {
const extensionFlagsText =
extensionFlags && extensionFlags.length > 0
? `\n${chalk.bold("Extension CLI Flags:")}\n${extensionFlags
.map((flag) => {
const value = flag.type === "string" ? " <value>" : "";
const description = flag.description ?? `Registered by ${flag.extensionPath}`;
return ` --${flag.name}${value}`.padEnd(30) + description;
})
.join("\n")}\n`
: "";
console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools
${chalk.bold("Usage:")}
@@ -229,7 +246,7 @@ ${chalk.bold("Options:")}
--help, -h Show this help
--version, -v Show version number
Extensions can register additional flags (e.g., --plan from plan-mode extension).
Extensions can register additional flags (e.g., --plan from plan-mode extension).${extensionFlagsText}
${chalk.bold("Examples:")}
# Interactive mode

View File

@@ -1,139 +1,36 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { getAgentDir } from "../config.js";
import { AuthStorage } from "./auth-storage.js";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import type { AgentSession } from "./agent-session.js";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.js";
import type { SessionStartEvent } from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import { type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
import type { CreateAgentSessionResult } from "./sdk.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import type { Tool } from "./tools/index.js";
/**
* Stable bootstrap inputs reused whenever the active runtime is replaced.
* Result returned by runtime creation.
*
* Use this for process-level wiring that should survive `/new`, `/resume`,
* `/fork`, and import flows. Session-local state belongs in the session file
* or in settings, not here.
* The caller gets the created session, its cwd-bound services, and all
* diagnostics collected during setup.
*/
export interface AgentSessionRuntimeBootstrap {
/** Agent directory used for auth, models, settings, sessions, and resource discovery. */
agentDir?: string;
/** Optional shared auth storage. If omitted, file-backed storage under agentDir is used. */
authStorage?: AuthStorage;
/** Initial model for the first created session runtime. */
model?: Model<any>;
/** Initial thinking level for the first created session runtime. */
thinkingLevel?: ThinkingLevel;
/** Optional scoped model list for model cycling and selection. */
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
/** Built-in tool set override. */
tools?: Tool[];
/** Additional custom tools registered directly through the SDK. */
customTools?: ToolDefinition[];
/**
* Resource loader input used for each created runtime.
*
* Pass either a factory that creates a fully custom ResourceLoader for the
* target cwd, or DefaultResourceLoader options without cwd/agentDir/
* settingsManager, which are supplied by the runtime.
*/
resourceLoader?:
| ((cwd: string, agentDir: string) => Promise<ResourceLoader>)
| Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
export interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResult {
services: AgentSessionServices;
diagnostics: AgentSessionRuntimeDiagnostic[];
}
/** Options for creating one concrete runtime instance. */
export interface CreateAgentSessionRuntimeOptions {
/** Working directory for this runtime instance. */
cwd: string;
/** Optional preselected session manager. If omitted, normal session resolution applies. */
sessionManager?: SessionManager;
/** Optional preloaded resource loader to reuse instead of creating and reloading one. */
resourceLoader?: ResourceLoader;
/** Optional session_start metadata to emit when the runtime binds extensions. */
sessionStartEvent?: SessionStartEvent;
}
type AgentSessionRuntime = CreateAgentSessionResult & {
/**
* Creates a full runtime for a target cwd and session manager.
*
* The factory closes over process-global fixed inputs, recreates cwd-bound
* services for the effective cwd, resolves session options against those
* services, and finally creates the AgentSession.
*/
export type CreateAgentSessionRuntimeFactory = (options: {
cwd: string;
agentDir: string;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
settingsManager: SettingsManager;
resourceLoader: ResourceLoader;
sessionManager: SessionManager;
};
/**
* Create one runtime instance containing an AgentSession plus the cwd-bound
* services it depends on.
*
* Most SDK callers should keep the returned value wrapped in an
* AgentSessionRuntimeHost instead of holding it directly. The host owns
* replacing the runtime when switching sessions across files or working
* directories.
*/
export async function createAgentSessionRuntime(
bootstrap: AgentSessionRuntimeBootstrap,
options: CreateAgentSessionRuntimeOptions,
): Promise<AgentSessionRuntime> {
const cwd = options.cwd;
const agentDir = bootstrap.agentDir ?? getAgentDir();
const authStorage = bootstrap.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = SettingsManager.create(cwd, agentDir);
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const resourceLoader =
options.resourceLoader ??
(typeof bootstrap.resourceLoader === "function"
? await bootstrap.resourceLoader(cwd, agentDir)
: new DefaultResourceLoader({
...(bootstrap.resourceLoader ?? {}),
cwd,
agentDir,
settingsManager,
}));
if (!options.resourceLoader) {
await resourceLoader.reload();
}
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config } of extensionsResult.runtime.pendingProviderRegistrations) {
modelRegistry.registerProvider(name, config);
}
extensionsResult.runtime.pendingProviderRegistrations = [];
const created = await createAgentSession({
cwd,
agentDir,
authStorage,
modelRegistry,
settingsManager,
resourceLoader,
sessionManager: options.sessionManager,
model: bootstrap.model,
thinkingLevel: bootstrap.thinkingLevel,
scopedModels: bootstrap.scopedModels,
tools: bootstrap.tools,
customTools: bootstrap.customTools,
sessionStartEvent: options.sessionStartEvent,
});
return {
...created,
cwd,
agentDir,
authStorage,
modelRegistry,
settingsManager,
resourceLoader,
sessionManager: created.session.sessionManager,
};
}
sessionStartEvent?: SessionStartEvent;
}) => Promise<CreateAgentSessionRuntimeResult>;
function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") {
@@ -147,28 +44,46 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
}
/**
* Stable wrapper around a replaceable AgentSession runtime.
* Owns the current AgentSession plus its cwd-bound services.
*
* Use this when your application needs `/new`, `/resume`, `/fork`, or import
* behavior. After replacement, read `session` again and rebind any
* session-local subscriptions or extension bindings.
* Session replacement methods tear down the current runtime first, then create
* and apply the next runtime. If creation fails, the error is propagated to the
* caller. The caller is responsible for user-facing error handling.
*/
export class AgentSessionRuntimeHost {
export class AgentSessionRuntime {
constructor(
private readonly bootstrap: AgentSessionRuntimeBootstrap,
private runtime: AgentSessionRuntime,
private _session: AgentSession,
private _services: AgentSessionServices,
private readonly createRuntime: CreateAgentSessionRuntimeFactory,
private _diagnostics: AgentSessionRuntimeDiagnostic[] = [],
private _modelFallbackMessage?: string,
) {}
/** The currently active session instance. Re-read this after runtime replacement. */
get session() {
return this.runtime.session;
get services(): AgentSessionServices {
return this._services;
}
get session(): AgentSession {
return this._session;
}
get cwd(): string {
return this._services.cwd;
}
get diagnostics(): readonly AgentSessionRuntimeDiagnostic[] {
return this._diagnostics;
}
get modelFallbackMessage(): string | undefined {
return this._modelFallbackMessage;
}
private async emitBeforeSwitch(
reason: "new" | "resume",
targetSessionFile?: string,
): Promise<{ cancelled: boolean }> {
const runner = this.runtime.session.extensionRunner;
const runner = this.session.extensionRunner;
if (!runner?.hasHandlers("session_before_switch")) {
return { cancelled: false };
}
@@ -182,7 +97,7 @@ export class AgentSessionRuntimeHost {
}
private async emitBeforeFork(entryId: string): Promise<{ cancelled: boolean }> {
const runner = this.runtime.session.extensionRunner;
const runner = this.session.extensionRunner;
if (!runner?.hasHandlers("session_before_fork")) {
return { cancelled: false };
}
@@ -194,48 +109,43 @@ export class AgentSessionRuntimeHost {
return { cancelled: result?.cancel === true };
}
private async replace(options: CreateAgentSessionRuntimeOptions): Promise<void> {
const nextRuntime = await createAgentSessionRuntime(this.bootstrap, options);
await emitSessionShutdownEvent(this.runtime.session.extensionRunner);
this.runtime.session.dispose();
if (process.cwd() !== nextRuntime.cwd) {
process.chdir(nextRuntime.cwd);
}
this.runtime = nextRuntime;
private async teardownCurrent(): Promise<void> {
await emitSessionShutdownEvent(this.session.extensionRunner);
this.session.dispose();
}
private apply(result: CreateAgentSessionRuntimeResult): void {
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
this._session = result.session;
this._services = result.services;
this._diagnostics = result.diagnostics;
this._modelFallbackMessage = result.modelFallbackMessage;
}
/**
* Replace the active runtime with one opened from an existing session file.
*
* Emits `session_before_switch` before replacement and returns
* `{ cancelled: true }` if an extension vetoes the switch.
*/
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
if (beforeResult.cancelled) {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
const previousSessionFile = this.session.sessionFile;
const sessionManager = SessionManager.open(sessionPath);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
}),
);
return { cancelled: false };
}
/**
* Replace the active runtime with a fresh session in the current cwd.
*
* `setup` runs after replacement against the new session manager, which lets
* callers seed entries before normal use begins.
*/
async newSession(options?: {
/** Optional parent session path recorded in the new session header. */
parentSession?: string;
/** Optional callback for seeding the new session manager after replacement. */
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("new");
@@ -243,94 +153,106 @@ export class AgentSessionRuntimeHost {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
const sessionDir = this.runtime.sessionManager.getSessionDir();
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
const previousSessionFile = this.session.sessionFile;
const sessionDir = this.session.sessionManager.getSessionDir();
const sessionManager = SessionManager.create(this.cwd, sessionDir);
if (options?.parentSession) {
sessionManager.newSession({ parentSession: options.parentSession });
}
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
}),
);
if (options?.setup) {
await options.setup(this.runtime.sessionManager);
this.runtime.session.agent.state.messages = this.runtime.sessionManager.buildSessionContext().messages;
await options.setup(this.session.sessionManager);
this.session.agent.state.messages = this.session.sessionManager.buildSessionContext().messages;
}
return { cancelled: false };
}
/**
* Replace the active runtime with a fork rooted at the given user-message
* entry.
*
* Returns the selected user text so UIs can restore it into the editor after
* the fork completes.
*/
async fork(entryId: string): Promise<{ cancelled: boolean; selectedText?: string }> {
const beforeResult = await this.emitBeforeFork(entryId);
if (beforeResult.cancelled) {
return { cancelled: true };
}
const selectedEntry = this.runtime.sessionManager.getEntry(entryId);
const selectedEntry = this.session.sessionManager.getEntry(entryId);
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry ID for forking");
}
const previousSessionFile = this.runtime.session.sessionFile;
const previousSessionFile = this.session.sessionFile;
const selectedText = extractUserMessageText(selectedEntry.message.content);
if (this.runtime.sessionManager.isPersisted()) {
const currentSessionFile = this.runtime.session.sessionFile!;
const sessionDir = this.runtime.sessionManager.getSessionDir();
if (this.session.sessionManager.isPersisted()) {
const currentSessionFile = this.session.sessionFile;
if (!currentSessionFile) {
throw new Error("Persisted session is missing a session file");
}
const sessionDir = this.session.sessionManager.getSessionDir();
if (!selectedEntry.parentId) {
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
const sessionManager = SessionManager.create(this.cwd, sessionDir);
sessionManager.newSession({ parentSession: currentSessionFile });
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
}),
);
return { cancelled: false, selectedText };
}
const sourceManager = SessionManager.open(currentSessionFile, sessionDir);
const forkedSessionPath = sourceManager.createBranchedSession(selectedEntry.parentId)!;
const forkedSessionPath = sourceManager.createBranchedSession(selectedEntry.parentId);
if (!forkedSessionPath) {
throw new Error("Failed to create forked session");
}
const sessionManager = SessionManager.open(forkedSessionPath, sessionDir);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
}),
);
return { cancelled: false, selectedText };
}
const sessionManager = this.runtime.sessionManager;
const sessionManager = this.session.sessionManager;
if (!selectedEntry.parentId) {
sessionManager.newSession({ parentSession: this.runtime.session.sessionFile });
sessionManager.newSession({ parentSession: this.session.sessionFile });
} else {
sessionManager.createBranchedSession(selectedEntry.parentId);
}
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
}),
);
return { cancelled: false, selectedText };
}
/**
* Import a JSONL session file into the current session directory and replace
* the active runtime with the imported session.
*/
async importFromJsonl(inputPath: string): Promise<{ cancelled: boolean }> {
const resolvedPath = resolve(inputPath);
if (!existsSync(resolvedPath)) {
throw new Error(`File not found: ${resolvedPath}`);
}
const sessionDir = this.runtime.sessionManager.getSessionDir();
const sessionDir = this.session.sessionManager.getSessionDir();
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
@@ -341,23 +263,63 @@ export class AgentSessionRuntimeHost {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
const previousSessionFile = this.session.sessionFile;
if (resolve(destinationPath) !== resolvedPath) {
copyFileSync(resolvedPath, destinationPath);
}
const sessionManager = SessionManager.open(destinationPath, sessionDir);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
}),
);
return { cancelled: false };
}
/** Emit session shutdown for the active runtime and dispose it permanently. */
async dispose(): Promise<void> {
await emitSessionShutdownEvent(this.runtime.session.extensionRunner);
this.runtime.session.dispose();
await emitSessionShutdownEvent(this.session.extensionRunner);
this.session.dispose();
}
}
/**
* Create the initial runtime from a runtime factory and initial session target.
*
* The same factory is stored on the returned AgentSessionRuntime and reused for
* later /new, /resume, /fork, and import flows.
*/
export async function createAgentSessionRuntime(
createRuntime: CreateAgentSessionRuntimeFactory,
options: {
cwd: string;
agentDir: string;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
},
): Promise<AgentSessionRuntime> {
const result = await createRuntime(options);
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
return new AgentSessionRuntime(
result.session,
result.services,
createRuntime,
result.diagnostics,
result.modelFallbackMessage,
);
}
export {
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
type CreateAgentSessionFromServicesOptions,
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";

View File

@@ -0,0 +1,197 @@
import { join } from "node:path";
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { getAgentDir } from "../config.js";
import { AuthStorage } from "./auth-storage.js";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import { type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
import type { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import type { Tool } from "./tools/index.js";
/**
* Non-fatal issues collected while creating services or sessions.
*
* Runtime creation returns diagnostics to the caller instead of printing or
* exiting. The app layer decides whether warnings should be shown and whether
* errors should abort startup.
*/
export interface AgentSessionRuntimeDiagnostic {
type: "info" | "warning" | "error";
message: string;
}
/**
* Inputs for creating cwd-bound runtime services.
*
* These services are recreated whenever the effective session cwd changes.
* CLI-provided resource paths should be resolved to absolute paths before they
* reach this function, so later cwd switches do not reinterpret them.
*/
export interface CreateAgentSessionServicesOptions {
cwd: string;
agentDir?: string;
authStorage?: AuthStorage;
settingsManager?: SettingsManager;
modelRegistry?: ModelRegistry;
extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
}
/**
* Inputs for creating an AgentSession from already-created services.
*
* Use this after services exist and any cwd-bound model/tool/session options
* have been resolved against those services.
*/
export interface CreateAgentSessionFromServicesOptions {
services: AgentSessionServices;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
model?: Model<any>;
thinkingLevel?: ThinkingLevel;
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
tools?: Tool[];
customTools?: ToolDefinition[];
}
/**
* Coherent cwd-bound runtime services for one effective session cwd.
*
* This is infrastructure only. The AgentSession itself is created separately so
* session options can be resolved against these services first.
*/
export interface AgentSessionServices {
cwd: string;
agentDir: string;
authStorage: AuthStorage;
settingsManager: SettingsManager;
modelRegistry: ModelRegistry;
resourceLoader: ResourceLoader;
diagnostics: AgentSessionRuntimeDiagnostic[];
}
function applyExtensionFlagValues(
resourceLoader: ResourceLoader,
extensionFlagValues: Map<string, boolean | string> | undefined,
): AgentSessionRuntimeDiagnostic[] {
if (!extensionFlagValues) {
return [];
}
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();
const registeredFlags = new Map<string, { type: "boolean" | "string" }>();
for (const extension of extensionsResult.extensions) {
for (const [name, flag] of extension.flags) {
registeredFlags.set(name, { type: flag.type });
}
}
const unknownFlags: string[] = [];
for (const [name, value] of extensionFlagValues) {
const flag = registeredFlags.get(name);
if (!flag) {
unknownFlags.push(name);
continue;
}
if (flag.type === "boolean") {
extensionsResult.runtime.flagValues.set(name, true);
continue;
}
if (typeof value === "string") {
extensionsResult.runtime.flagValues.set(name, value);
continue;
}
diagnostics.push({
type: "error",
message: `Extension flag "--${name}" requires a value`,
});
}
if (unknownFlags.length > 0) {
diagnostics.push({
type: "error",
message: `Unknown option${unknownFlags.length === 1 ? "" : "s"}: ${unknownFlags.map((name) => `--${name}`).join(", ")}`,
});
}
return diagnostics;
}
/**
* Create cwd-bound runtime services.
*
* Returns services plus diagnostics. It does not create an AgentSession.
*/
export async function createAgentSessionServices(
options: CreateAgentSessionServicesOptions,
): Promise<AgentSessionServices> {
const cwd = options.cwd;
const agentDir = options.agentDir ?? getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const resourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}),
cwd,
agentDir,
settingsManager,
});
await resourceLoader.reload();
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diagnostics.push({
type: "error",
message: `Extension "${extensionPath}" error: ${message}`,
});
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
return {
cwd,
agentDir,
authStorage,
settingsManager,
modelRegistry,
resourceLoader,
diagnostics,
};
}
/**
* Create an AgentSession from previously created services.
*
* This keeps session creation separate from service creation so callers can
* resolve model, thinking, tools, and other session inputs against the target
* cwd before constructing the session.
*/
export async function createAgentSessionFromServices(
options: CreateAgentSessionFromServicesOptions,
): Promise<CreateAgentSessionResult> {
return createAgentSession({
cwd: options.services.cwd,
agentDir: options.services.agentDir,
authStorage: options.services.authStorage,
settingsManager: options.services.settingsManager,
modelRegistry: options.services.modelRegistry,
resourceLoader: options.services.resourceLoader,
sessionManager: options.sessionManager,
model: options.model,
thinkingLevel: options.thinkingLevel,
scopedModels: options.scopedModels,
tools: options.tools,
customTools: options.customTools,
sessionStartEvent: options.sessionStartEvent,
});
}

View File

@@ -2350,7 +2350,7 @@ export class AgentSession {
async reload(): Promise<void> {
const previousFlagValues = this._extensionRunner?.getFlagValues();
await this._extensionRunner?.emit({ type: "session_shutdown" });
this.settingsManager.reload();
await this.settingsManager.reload();
resetApiProviders();
await this._resourceLoader.reload();
this._buildRuntime({

View File

@@ -116,9 +116,6 @@ export type {
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionCompactEvent,
SessionDirectoryEvent,
SessionDirectoryHandler,
SessionDirectoryResult,
SessionEvent,
SessionShutdownEvent,
// Events - Session

View File

@@ -441,12 +441,6 @@ export interface ResourcesDiscoverResult {
// Session Events
// ============================================================================
/** Fired before session manager creation to allow custom session directory resolution */
export interface SessionDirectoryEvent {
type: "session_directory";
cwd: string;
}
/** Fired when a session is started, loaded, or reloaded */
export interface SessionStartEvent {
type: "session_start";
@@ -522,7 +516,6 @@ export interface SessionTreeEvent {
}
export type SessionEvent =
| SessionDirectoryEvent
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionBeforeForkEvent
@@ -921,16 +914,6 @@ export interface BeforeAgentStartEventResult {
systemPrompt?: string;
}
export interface SessionDirectoryResult {
/** Custom session directory path. If multiple extensions return this, the last one wins. */
sessionDir?: string;
}
/** Special startup-only handler. Unlike other events, this receives no ExtensionContext. */
export type SessionDirectoryHandler = (
event: SessionDirectoryEvent,
) => Promise<SessionDirectoryResult | undefined> | SessionDirectoryResult | undefined;
export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
@@ -1006,7 +989,6 @@ export interface ExtensionAPI {
// =========================================================================
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_directory", handler: SessionDirectoryHandler): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(
event: "session_before_switch",

View File

@@ -12,11 +12,19 @@ export {
type SessionStats,
} from "./agent-session.js";
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeOptions,
AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
type CreateAgentSessionRuntimeResult,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
export {
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
type CreateAgentSessionFromServicesOptions,
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";
export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";

View File

@@ -6,7 +6,7 @@ import {
TUI_KEYBINDINGS,
KeybindingsManager as TuiKeybindingsManager,
} from "@mariozechner/pi-tui";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
@@ -219,7 +219,7 @@ function toKeybindingsConfig(value: unknown): KeybindingsConfig {
return config;
}
function migrateKeybindingNames(rawConfig: Record<string, unknown>): {
export function migrateKeybindingsConfig(rawConfig: Record<string, unknown>): {
config: Record<string, unknown>;
migrated: boolean;
} {
@@ -269,18 +269,6 @@ function loadRawConfig(path: string): Record<string, unknown> | undefined {
}
}
export function migrateKeybindingsConfigFile(agentDir: string = getAgentDir()): boolean {
const configPath = join(agentDir, "keybindings.json");
const rawConfig = loadRawConfig(configPath);
if (!rawConfig) return false;
const { config, migrated } = migrateKeybindingNames(rawConfig);
if (!migrated) return false;
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
return true;
}
export class KeybindingsManager extends TuiKeybindingsManager {
private configPath: string | undefined;
@@ -307,7 +295,7 @@ export class KeybindingsManager extends TuiKeybindingsManager {
private static loadFromFile(path: string): KeybindingsConfig {
const rawConfig = loadRawConfig(path);
if (!rawConfig) return {};
return toKeybindingsConfig(migrateKeybindingNames(rawConfig).config);
return toKeybindingsConfig(migrateKeybindingsConfig(rawConfig).config);
}
}

View File

@@ -57,11 +57,21 @@ export interface PackageUpdate {
scope: Exclude<SourceScope, "temporary">;
}
export interface ConfiguredPackage {
source: string;
scope: "user" | "project";
filtered: boolean;
installedPath?: string;
}
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
install(source: string, options?: { local?: boolean }): Promise<void>;
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
remove(source: string, options?: { local?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
update(source?: string): Promise<void>;
listConfiguredPackages(): ConfiguredPackage[];
resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
@@ -837,6 +847,34 @@ export class DefaultPackageManager implements PackageManager {
return this.toResolvedPaths(accumulator);
}
listConfiguredPackages(): ConfiguredPackage[] {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const configuredPackages: ConfiguredPackage[] = [];
for (const pkg of globalSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "user",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "user"),
});
}
for (const pkg of projectSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "project",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "project"),
});
}
return configuredPackages;
}
async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
@@ -860,6 +898,11 @@ export class DefaultPackageManager implements PackageManager {
});
}
async installAndPersist(source: string, options?: { local?: boolean }): Promise<void> {
await this.install(source, options);
this.addSourceToSettings(source, options);
}
async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
@@ -879,6 +922,11 @@ export class DefaultPackageManager implements PackageManager {
});
}
async removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean> {
await this.remove(source, options);
return this.removeSourceFromSettings(source, options);
}
async update(source?: string): Promise<void> {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();

View File

@@ -315,6 +315,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
async reload(): Promise<void> {
await this.settingsManager.reload();
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true,
@@ -402,6 +403,11 @@ export class DefaultResourceLoader implements ResourceLoader {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
for (const p of this.additionalExtensionPaths) {
if (!existsSync(p)) {
extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` });
}
}
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath);
@@ -411,6 +417,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);
for (const p of this.additionalSkillPaths) {
if (!existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p });
}
}
const promptPaths = this.noPromptTemplates
? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)
@@ -418,6 +429,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastPromptPaths = promptPaths;
this.updatePromptsFromPaths(promptPaths, metadataByPath);
for (const p of this.additionalPromptTemplatePaths) {
if (!existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) {
this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p });
}
}
const themePaths = this.noThemes
? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)
@@ -425,6 +441,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastThemePaths = themePaths;
this.updateThemesFromPaths(themePaths, metadataByPath);
for (const p of this.additionalThemePaths) {
if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p });
}
}
const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) };
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;

View File

@@ -86,12 +86,7 @@ export interface CreateAgentSessionResult {
// Re-exports
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeOptions,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
export * from "./agent-session-runtime.js";
export type {
ExtensionAPI,
ExtensionCommandContext,

View File

@@ -359,7 +359,8 @@ export class SettingsManager {
return structuredClone(this.projectSettings);
}
reload(): void {
async reload(): Promise<void> {
await this.writeQueue;
const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
if (!globalLoad.error) {
this.globalSettings = globalLoad.settings;

View File

@@ -156,14 +156,20 @@ export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./co
export { DefaultResourceLoader } from "./core/resource-loader.js";
// SDK for programmatic usage
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
AgentSessionRuntime,
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
type CreateAgentSessionFromServicesOptions,
type CreateAgentSessionOptions,
type CreateAgentSessionResult,
type CreateAgentSessionRuntimeOptions,
type CreateAgentSessionRuntimeFactory,
type CreateAgentSessionRuntimeResult,
type CreateAgentSessionServicesOptions,
// Factory
createAgentSession,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
createBashTool,
// Tool factories (for custom cwd)
createCodingTools,

View File

@@ -0,0 +1,247 @@
import chalk from "chalk";
import { APP_NAME, getAgentDir } from "./config.js";
import { DefaultPackageManager } from "./core/package-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
export type PackageCommand = "install" | "remove" | "update" | "list";
interface PackageCommandOptions {
command: PackageCommand;
source?: string;
local: boolean;
help: boolean;
invalidOption?: string;
}
function reportSettingsErrors(settingsManager: SettingsManager, context: string): void {
const errors = settingsManager.drainErrors();
for (const { scope, error } of errors) {
console.error(chalk.yellow(`Warning (${context}, ${scope} settings): ${error.message}`));
if (error.stack) {
console.error(chalk.dim(error.stack));
}
}
}
function getPackageCommandUsage(command: PackageCommand): string {
switch (command) {
case "install":
return `${APP_NAME} install <source> [-l]`;
case "remove":
return `${APP_NAME} remove <source> [-l]`;
case "update":
return `${APP_NAME} update [source]`;
case "list":
return `${APP_NAME} list`;
}
}
function printPackageCommandHelp(command: PackageCommand): void {
switch (command) {
case "install":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("install")}
Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
Examples:
${APP_NAME} install npm:@foo/bar
${APP_NAME} install git:github.com/user/repo
${APP_NAME} install git:git@github.com:user/repo
${APP_NAME} install https://github.com/user/repo
${APP_NAME} install ssh://git@github.com/user/repo
${APP_NAME} install ./local/path
`);
return;
case "remove":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("remove")}
Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
Examples:
${APP_NAME} remove npm:@foo/bar
${APP_NAME} uninstall npm:@foo/bar
`);
return;
case "update":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("update")}
Update installed packages.
If <source> is provided, only that package is updated.
`);
return;
case "list":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("list")}
List installed packages from user and project settings.
`);
return;
}
}
function parsePackageCommand(args: string[]): PackageCommandOptions | undefined {
const [rawCommand, ...rest] = args;
let command: PackageCommand | undefined;
if (rawCommand === "uninstall") {
command = "remove";
} else if (rawCommand === "install" || rawCommand === "remove" || rawCommand === "update" || rawCommand === "list") {
command = rawCommand;
}
if (!command) {
return undefined;
}
let local = false;
let help = false;
let invalidOption: string | undefined;
let source: string | undefined;
for (const arg of rest) {
if (arg === "-h" || arg === "--help") {
help = true;
continue;
}
if (arg === "-l" || arg === "--local") {
if (command === "install" || command === "remove") {
local = true;
} else {
invalidOption = invalidOption ?? arg;
}
continue;
}
if (arg.startsWith("-")) {
invalidOption = invalidOption ?? arg;
continue;
}
if (!source) {
source = arg;
}
}
return { command, source, local, help, invalidOption };
}
export async function handlePackageCommand(args: string[]): Promise<boolean> {
const options = parsePackageCommand(args);
if (!options) {
return false;
}
if (options.help) {
printPackageCommandHelp(options.command);
return true;
}
if (options.invalidOption) {
console.error(chalk.red(`Unknown option ${options.invalidOption} for "${options.command}".`));
console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getPackageCommandUsage(options.command)}".`));
process.exitCode = 1;
return true;
}
const source = options.source;
if ((options.command === "install" || options.command === "remove") && !source) {
console.error(chalk.red(`Missing ${options.command} source.`));
console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`));
process.exitCode = 1;
return true;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager, "package command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
process.stdout.write(chalk.dim(`${event.message}\n`));
}
});
try {
switch (options.command) {
case "install":
await packageManager.installAndPersist(source!, { local: options.local });
console.log(chalk.green(`Installed ${source}`));
return true;
case "remove": {
const removed = await packageManager.removeAndPersist(source!, { local: options.local });
if (!removed) {
console.error(chalk.red(`No matching package found for ${source}`));
process.exitCode = 1;
return true;
}
console.log(chalk.green(`Removed ${source}`));
return true;
}
case "list": {
const configuredPackages = packageManager.listConfiguredPackages();
const userPackages = configuredPackages.filter((pkg) => pkg.scope === "user");
const projectPackages = configuredPackages.filter((pkg) => pkg.scope === "project");
if (configuredPackages.length === 0) {
console.log(chalk.dim("No packages installed."));
return true;
}
const formatPackage = (pkg: (typeof configuredPackages)[number]) => {
const display = pkg.filtered ? `${pkg.source} (filtered)` : pkg.source;
console.log(` ${display}`);
if (pkg.installedPath) {
console.log(chalk.dim(` ${pkg.installedPath}`));
}
};
if (userPackages.length > 0) {
console.log(chalk.bold("User packages:"));
for (const pkg of userPackages) {
formatPackage(pkg);
}
}
if (projectPackages.length > 0) {
if (userPackages.length > 0) console.log();
console.log(chalk.bold("Project packages:"));
for (const pkg of projectPackages) {
formatPackage(pkg);
}
}
return true;
}
case "update":
await packageManager.update(source);
if (source) {
console.log(chalk.green(`Updated ${source}`));
} else {
console.log(chalk.green("Updated packages"));
}
return true;
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown package command error";
console.error(chalk.red(`Error: ${message}`));
process.exitCode = 1;
return true;
}
}

View File

@@ -9,27 +9,23 @@ import { resolve } from "node:path";
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { createInterface } from "readline";
import { type Args, parseArgs, printHelp } from "./cli/args.js";
import { selectConfig } from "./cli/config-selector.js";
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.js";
import { processFileArguments } from "./cli/file-processor.js";
import { buildInitialMessage } from "./cli/initial-message.js";
import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js";
import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import { getAgentDir, getModelsPath, VERSION } from "./config.js";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.js";
import {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
createAgentSessionRuntime,
} from "./core/agent-session-runtime.js";
type AgentSessionRuntimeDiagnostic,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./core/agent-session-services.js";
import { AuthStorage } from "./core/auth-storage.js";
import { exportFromFile } from "./core/export-html/index.js";
import type { LoadExtensionsResult } from "./core/extensions/index.js";
import { migrateKeybindingsConfigFile } from "./core/keybindings.js";
import { ModelRegistry } from "./core/model-registry.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { restoreStdout, takeOverStdout } from "./core/output-guard.js";
import { DefaultPackageManager } from "./core/package-manager.js";
import { DefaultResourceLoader } from "./core/resource-loader.js";
import type { CreateAgentSessionOptions } from "./core/sdk.js";
import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
@@ -38,6 +34,7 @@ import { allTools } from "./core/tools/index.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.js";
/**
* Read all content from piped stdin.
@@ -62,13 +59,21 @@ async function readPipedStdin(): Promise<string | undefined> {
});
}
function reportSettingsErrors(settingsManager: SettingsManager, context: string): void {
const errors = settingsManager.drainErrors();
for (const { scope, error } of errors) {
console.error(chalk.yellow(`Warning (${context}, ${scope} settings): ${error.message}`));
if (error.stack) {
console.error(chalk.dim(error.stack));
}
function collectSettingsDiagnostics(
settingsManager: SettingsManager,
context: string,
): AgentSessionRuntimeDiagnostic[] {
return settingsManager.drainErrors().map(({ scope, error }) => ({
type: "warning",
message: `(${context}, ${scope} settings) ${error.message}`,
}));
}
function reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void {
for (const diagnostic of diagnostics) {
const color = diagnostic.type === "error" ? chalk.red : diagnostic.type === "warning" ? chalk.yellow : chalk.dim;
const prefix = diagnostic.type === "error" ? "Error: " : diagnostic.type === "warning" ? "Warning: " : "";
console.error(color(`${prefix}${diagnostic.message}`));
}
}
@@ -77,243 +82,23 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
type PackageCommand = "install" | "remove" | "update" | "list";
type AppMode = "interactive" | "print" | "json" | "rpc";
interface PackageCommandOptions {
command: PackageCommand;
source?: string;
local: boolean;
help: boolean;
invalidOption?: string;
function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode {
if (parsed.mode === "rpc") {
return "rpc";
}
if (parsed.mode === "json") {
return "json";
}
if (parsed.print || !stdinIsTTY) {
return "print";
}
return "interactive";
}
function getPackageCommandUsage(command: PackageCommand): string {
switch (command) {
case "install":
return `${APP_NAME} install <source> [-l]`;
case "remove":
return `${APP_NAME} remove <source> [-l]`;
case "update":
return `${APP_NAME} update [source]`;
case "list":
return `${APP_NAME} list`;
}
}
function printPackageCommandHelp(command: PackageCommand): void {
switch (command) {
case "install":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("install")}
Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
Examples:
${APP_NAME} install npm:@foo/bar
${APP_NAME} install git:github.com/user/repo
${APP_NAME} install git:git@github.com:user/repo
${APP_NAME} install https://github.com/user/repo
${APP_NAME} install ssh://git@github.com/user/repo
${APP_NAME} install ./local/path
`);
return;
case "remove":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("remove")}
Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
Examples:
${APP_NAME} remove npm:@foo/bar
${APP_NAME} uninstall npm:@foo/bar
`);
return;
case "update":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("update")}
Update installed packages.
If <source> is provided, only that package is updated.
`);
return;
case "list":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("list")}
List installed packages from user and project settings.
`);
return;
}
}
function parsePackageCommand(args: string[]): PackageCommandOptions | undefined {
const [rawCommand, ...rest] = args;
let command: PackageCommand | undefined;
if (rawCommand === "uninstall") {
command = "remove";
} else if (rawCommand === "install" || rawCommand === "remove" || rawCommand === "update" || rawCommand === "list") {
command = rawCommand;
}
if (!command) {
return undefined;
}
let local = false;
let help = false;
let invalidOption: string | undefined;
let source: string | undefined;
for (const arg of rest) {
if (arg === "-h" || arg === "--help") {
help = true;
continue;
}
if (arg === "-l" || arg === "--local") {
if (command === "install" || command === "remove") {
local = true;
} else {
invalidOption = invalidOption ?? arg;
}
continue;
}
if (arg.startsWith("-")) {
invalidOption = invalidOption ?? arg;
continue;
}
if (!source) {
source = arg;
}
}
return { command, source, local, help, invalidOption };
}
async function handlePackageCommand(args: string[]): Promise<boolean> {
const options = parsePackageCommand(args);
if (!options) {
return false;
}
if (options.help) {
printPackageCommandHelp(options.command);
return true;
}
if (options.invalidOption) {
console.error(chalk.red(`Unknown option ${options.invalidOption} for "${options.command}".`));
console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getPackageCommandUsage(options.command)}".`));
process.exitCode = 1;
return true;
}
const source = options.source;
if ((options.command === "install" || options.command === "remove") && !source) {
console.error(chalk.red(`Missing ${options.command} source.`));
console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`));
process.exitCode = 1;
return true;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager, "package command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
process.stdout.write(chalk.dim(`${event.message}\n`));
}
});
try {
switch (options.command) {
case "install":
await packageManager.install(source!, { local: options.local });
packageManager.addSourceToSettings(source!, { local: options.local });
console.log(chalk.green(`Installed ${source}`));
return true;
case "remove": {
await packageManager.remove(source!, { local: options.local });
const removed = packageManager.removeSourceFromSettings(source!, { local: options.local });
if (!removed) {
console.error(chalk.red(`No matching package found for ${source}`));
process.exitCode = 1;
return true;
}
console.log(chalk.green(`Removed ${source}`));
return true;
}
case "list": {
const globalSettings = settingsManager.getGlobalSettings();
const projectSettings = settingsManager.getProjectSettings();
const globalPackages = globalSettings.packages ?? [];
const projectPackages = projectSettings.packages ?? [];
if (globalPackages.length === 0 && projectPackages.length === 0) {
console.log(chalk.dim("No packages installed."));
return true;
}
const formatPackage = (pkg: (typeof globalPackages)[number], scope: "user" | "project") => {
const source = typeof pkg === "string" ? pkg : pkg.source;
const filtered = typeof pkg === "object";
const display = filtered ? `${source} (filtered)` : source;
console.log(` ${display}`);
const path = packageManager.getInstalledPath(source, scope);
if (path) {
console.log(chalk.dim(` ${path}`));
}
};
if (globalPackages.length > 0) {
console.log(chalk.bold("User packages:"));
for (const pkg of globalPackages) {
formatPackage(pkg, "user");
}
}
if (projectPackages.length > 0) {
if (globalPackages.length > 0) console.log();
console.log(chalk.bold("Project packages:"));
for (const pkg of projectPackages) {
formatPackage(pkg, "project");
}
}
return true;
}
case "update":
await packageManager.update(source);
if (source) {
console.log(chalk.green(`Updated ${source}`));
} else {
console.log(chalk.green("Updated packages"));
}
return true;
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown package command error";
console.error(chalk.red(`Error: ${message}`));
process.exitCode = 1;
return true;
}
function toPrintOutputMode(appMode: AppMode): Exclude<Mode, "rpc"> {
return appMode === "json" ? "json" : "text";
}
async function prepareInitialMessage(
@@ -389,32 +174,6 @@ async function promptConfirm(message: string): Promise<boolean> {
});
}
/** Helper to call CLI-only session_directory handlers before the initial session manager is created */
async function callSessionDirectoryHook(extensions: LoadExtensionsResult, cwd: string): Promise<string | undefined> {
let customSessionDir: string | undefined;
for (const ext of extensions.extensions) {
const handlers = ext.handlers.get("session_directory");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event = { type: "session_directory" as const, cwd };
const result = (await handler(event)) as { sessionDir?: string } | undefined;
if (result?.sessionDir) {
customSessionDir = result.sessionDir;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(chalk.red(`Extension "${ext.path}" session_directory handler failed: ${message}`));
}
}
}
return customSessionDir;
}
function validateForkFlags(parsed: Args): void {
if (!parsed.fork) return;
@@ -444,25 +203,21 @@ function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string)
async function createSessionManager(
parsed: Args,
cwd: string,
extensions: LoadExtensionsResult,
sessionDir: string | undefined,
settingsManager: SettingsManager,
): Promise<SessionManager | undefined> {
): Promise<SessionManager> {
if (parsed.noSession) {
return SessionManager.inMemory();
}
// Priority: CLI flag > settings.json > extension hook
const effectiveSessionDir =
parsed.sessionDir ?? settingsManager.getSessionDir() ?? (await callSessionDirectoryHook(extensions, cwd));
if (parsed.fork) {
const resolved = await resolveSessionPath(parsed.fork, cwd, effectiveSessionDir);
const resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir);
switch (resolved.type) {
case "path":
case "local":
case "global":
return forkSessionOrExit(resolved.path, cwd, effectiveSessionDir);
return forkSessionOrExit(resolved.path, cwd, sessionDir);
case "not_found":
console.error(chalk.red(`No session found matching '${resolved.arg}'`));
@@ -471,22 +226,21 @@ async function createSessionManager(
}
if (parsed.session) {
const resolved = await resolveSessionPath(parsed.session, cwd, effectiveSessionDir);
const resolved = await resolveSessionPath(parsed.session, cwd, sessionDir);
switch (resolved.type) {
case "path":
case "local":
return SessionManager.open(resolved.path, effectiveSessionDir);
return SessionManager.open(resolved.path, sessionDir);
case "global": {
// Session found in different project - ask user if they want to fork
console.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`));
const shouldFork = await promptConfirm("Fork this session into current directory?");
if (!shouldFork) {
console.log(chalk.dim("Aborted."));
process.exit(0);
}
return forkSessionOrExit(resolved.path, cwd, effectiveSessionDir);
return forkSessionOrExit(resolved.path, cwd, sessionDir);
}
case "not_found":
@@ -494,32 +248,46 @@ async function createSessionManager(
process.exit(1);
}
}
if (parsed.resume) {
initTheme(settingsManager.getTheme(), true);
try {
const selectedPath = await selectSession(
(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),
SessionManager.listAll,
);
if (!selectedPath) {
console.log(chalk.dim("No session selected"));
process.exit(0);
}
return SessionManager.open(selectedPath, sessionDir);
} finally {
stopThemeWatcher();
}
}
if (parsed.continue) {
return SessionManager.continueRecent(cwd, effectiveSessionDir);
return SessionManager.continueRecent(cwd, sessionDir);
}
// --resume is handled separately (needs picker UI)
// If effective session dir is set, create new session there
if (effectiveSessionDir) {
return SessionManager.create(cwd, effectiveSessionDir);
}
// Default case (new session) returns undefined, SDK will create one
return undefined;
return SessionManager.create(cwd, sessionDir);
}
function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
hasExistingSession: boolean,
modelRegistry: ModelRegistry,
settingsManager: SettingsManager,
): { options: CreateAgentSessionOptions; cliThinkingFromModel: boolean } {
): {
options: CreateAgentSessionOptions;
cliThinkingFromModel: boolean;
diagnostics: AgentSessionRuntimeDiagnostic[];
} {
const options: CreateAgentSessionOptions = {};
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
let cliThinkingFromModel = false;
if (sessionManager) {
options.sessionManager = sessionManager;
}
// Model from CLI
// - supports --provider <name> --model <pattern>
// - supports --model <provider>/<pattern>
@@ -530,11 +298,10 @@ function buildSessionOptions(
modelRegistry,
});
if (resolved.warning) {
console.warn(chalk.yellow(`Warning: ${resolved.warning}`));
diagnostics.push({ type: "warning", message: resolved.warning });
}
if (resolved.error) {
console.error(chalk.red(resolved.error));
process.exit(1);
diagnostics.push({ type: "error", message: resolved.error });
}
if (resolved.model) {
options.model = resolved.model;
@@ -547,7 +314,7 @@ function buildSessionOptions(
}
}
if (!options.model && scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
if (!options.model && scopedModels.length > 0 && !hasExistingSession) {
// Check if saved default is in scoped models - use it if so, otherwise first scoped model
const savedProvider = settingsManager.getDefaultProvider();
const savedModelId = settingsManager.getDefaultModel();
@@ -600,66 +367,13 @@ function buildSessionOptions(
options.tools = parsed.tools.map((name) => allTools[name]);
}
return { options, cliThinkingFromModel };
return { options, cliThinkingFromModel, diagnostics };
}
function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {
return paths?.map((value) => resolve(cwd, value));
}
function buildRuntimeBootstrap(
parsed: Args,
cwd: string,
agentDir: string,
authStorage: AuthStorage,
sessionOptions: CreateAgentSessionOptions,
): AgentSessionRuntimeBootstrap {
return {
agentDir,
authStorage,
model: sessionOptions.model,
thinkingLevel: sessionOptions.thinkingLevel,
scopedModels: sessionOptions.scopedModels,
tools: sessionOptions.tools,
customTools: sessionOptions.customTools,
resourceLoader: {
additionalExtensionPaths: resolveCliPaths(cwd, parsed.extensions),
additionalSkillPaths: resolveCliPaths(cwd, parsed.skills),
additionalPromptTemplatePaths: resolveCliPaths(cwd, parsed.promptTemplates),
additionalThemePaths: resolveCliPaths(cwd, parsed.themes),
noExtensions: parsed.noExtensions,
noSkills: parsed.noSkills,
noPromptTemplates: parsed.noPromptTemplates,
noThemes: parsed.noThemes,
systemPrompt: parsed.systemPrompt,
appendSystemPrompt: parsed.appendSystemPrompt,
},
};
}
async function handleConfigCommand(args: string[]): Promise<boolean> {
if (args[0] !== "config") {
return false;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve();
await selectConfig({
resolvedPaths,
settingsManager,
cwd,
agentDir,
});
process.exit(0);
}
export async function main(args: string[]) {
resetTimings();
const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE);
@@ -676,105 +390,28 @@ export async function main(args: string[]) {
return;
}
// First pass: parse args to get --extension paths
const firstPass = parseArgs(args);
time("parseArgs.firstPass");
const shouldTakeOverStdout = firstPass.mode !== undefined || firstPass.print || !process.stdin.isTTY;
const parsed = parseArgs(args);
if (parsed.diagnostics.length > 0) {
for (const d of parsed.diagnostics) {
const color = d.type === "error" ? chalk.red : chalk.yellow;
console.error(color(`${d.type === "error" ? "Error" : "Warning"}: ${d.message}`));
}
if (parsed.diagnostics.some((d) => d.type === "error")) {
process.exit(1);
}
}
time("parseArgs");
let appMode = resolveAppMode(parsed, process.stdin.isTTY);
const shouldTakeOverStdout = appMode !== "interactive";
if (shouldTakeOverStdout) {
takeOverStdout();
}
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
time("runMigrations");
// Early load extensions to discover their CLI flags
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager, "startup");
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage, getModelsPath());
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
additionalExtensionPaths: firstPass.extensions,
additionalSkillPaths: firstPass.skills,
additionalPromptTemplatePaths: firstPass.promptTemplates,
additionalThemePaths: firstPass.themes,
noExtensions: firstPass.noExtensions,
noSkills: firstPass.noSkills,
noPromptTemplates: firstPass.noPromptTemplates,
noThemes: firstPass.noThemes,
systemPrompt: firstPass.systemPrompt,
appendSystemPrompt: firstPass.appendSystemPrompt,
});
time("createResourceLoader");
await resourceLoader.reload();
time("resourceLoader.reload");
const extensionsResult: LoadExtensionsResult = resourceLoader.getExtensions();
for (const { path, error } of extensionsResult.errors) {
console.error(chalk.red(`Failed to load extension "${path}": ${error}`));
}
// Apply pending provider registrations from extensions immediately
// so they're available for model resolution before AgentSession is created
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`Extension "${extensionPath}" error: ${message}`));
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
for (const ext of extensionsResult.extensions) {
for (const [name, flag] of ext.flags) {
extensionFlags.set(name, { type: flag.type });
}
}
// Second pass: parse args with extension flags
const parsed = parseArgs(args, extensionFlags);
time("parseArgs.secondPass");
// Pass flag values to extensions via runtime
for (const [name, value] of parsed.unknownFlags) {
extensionsResult.runtime.flagValues.set(name, value);
}
if (parsed.version) {
console.log(VERSION);
process.exit(0);
}
if (parsed.help) {
printHelp();
process.exit(0);
}
if (parsed.listModels !== undefined) {
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
await listModels(modelRegistry, searchPattern);
process.exit(0);
}
// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC
let stdinContent: string | undefined;
if (parsed.mode !== "rpc") {
stdinContent = await readPipedStdin();
if (stdinContent !== undefined) {
// Force print mode since interactive mode requires a TTY for keyboard input
parsed.print = true;
}
}
time("readPipedStdin");
if (parsed.export) {
let result: string;
try {
@@ -789,9 +426,6 @@ export async function main(args: string[]) {
process.exit(0);
}
migrateKeybindingsConfigFile(agentDir);
time("migrateKeybindingsConfigFile");
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
process.exit(1);
@@ -799,90 +433,179 @@ export async function main(args: string[]) {
validateForkFlags(parsed);
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
time("runMigrations");
const cwd = process.cwd();
const agentDir = getAgentDir();
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
// Decide the final runtime cwd before creating cwd-bound runtime services.
// --session and --resume may select a session from another project, so project-local
// settings, resources, provider registrations, and models must be resolved only after
// the target session cwd is known. The startup-cwd settings manager is used only for
// sessionDir lookup during session selection.
const sessionManager = await createSessionManager(
parsed,
cwd,
parsed.sessionDir ?? startupSettingsManager.getSessionDir(),
startupSettingsManager,
);
time("createSessionManager");
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);
const authStorage = AuthStorage.create();
const createRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
agentDir,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
extensionFlagValues: parsed.unknownFlags,
resourceLoaderOptions: {
additionalExtensionPaths: resolvedExtensionPaths,
additionalSkillPaths: resolvedSkillPaths,
additionalPromptTemplatePaths: resolvedPromptTemplatePaths,
additionalThemePaths: resolvedThemePaths,
noExtensions: parsed.noExtensions,
noSkills: parsed.noSkills,
noPromptTemplates: parsed.noPromptTemplates,
noThemes: parsed.noThemes,
systemPrompt: parsed.systemPrompt,
appendSystemPrompt: parsed.appendSystemPrompt,
},
});
const { settingsManager, modelRegistry, resourceLoader } = services;
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
...services.diagnostics,
...collectSettingsDiagnostics(settingsManager, "runtime creation"),
...resourceLoader.getExtensions().errors.map(({ path, error }) => ({
type: "error" as const,
message: `Failed to load extension "${path}": ${error}`,
})),
];
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
const scopedModels =
modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRegistry) : [];
const {
options: sessionOptions,
cliThinkingFromModel,
diagnostics: sessionOptionDiagnostics,
} = buildSessionOptions(
parsed,
scopedModels,
sessionManager.buildSessionContext().messages.length > 0,
modelRegistry,
settingsManager,
);
diagnostics.push(...sessionOptionDiagnostics);
if (parsed.apiKey) {
if (!sessionOptions.model) {
diagnostics.push({
type: "error",
message: "--api-key requires a model to be specified via --model, --provider/--model, or --models",
});
} else {
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
}
}
const created = await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: sessionOptions.model,
thinkingLevel: sessionOptions.thinkingLevel,
scopedModels: sessionOptions.scopedModels,
tools: sessionOptions.tools,
customTools: sessionOptions.customTools,
});
const cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel;
if (created.session.model && cliThinkingOverride) {
let effectiveThinking = created.session.thinkingLevel;
if (!created.session.model.reasoning) {
effectiveThinking = "off";
} else if (effectiveThinking === "xhigh" && !supportsXhigh(created.session.model)) {
effectiveThinking = "high";
}
if (effectiveThinking !== created.session.thinkingLevel) {
created.session.setThinkingLevel(effectiveThinking);
}
}
return {
...created,
services,
diagnostics,
};
};
time("createRuntime");
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: sessionManager.getCwd(),
agentDir,
sessionManager,
});
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services;
if (parsed.help) {
const extensionFlags = resourceLoader
.getExtensions()
.extensions.flatMap((extension) => Array.from(extension.flags.values()));
printHelp(extensionFlags);
process.exit(0);
}
if (parsed.listModels !== undefined) {
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
await listModels(modelRegistry, searchPattern);
process.exit(0);
}
// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC
let stdinContent: string | undefined;
if (appMode !== "rpc") {
stdinContent = await readPipedStdin();
if (stdinContent !== undefined) {
appMode = "print";
}
}
time("readPipedStdin");
const { initialMessage, initialImages } = await prepareInitialMessage(
parsed,
settingsManager.getImageAutoResize(),
stdinContent,
);
time("prepareInitialMessage");
const isInteractive = !parsed.print && parsed.mode === undefined;
const startupBenchmark = isTruthyEnvFlag(process.env.PI_STARTUP_BENCHMARK);
if (startupBenchmark && !isInteractive) {
console.error(chalk.red("Error: PI_STARTUP_BENCHMARK only supports interactive mode"));
process.exit(1);
}
const mode = parsed.mode || "text";
initTheme(settingsManager.getTheme(), isInteractive);
initTheme(settingsManager.getTheme(), appMode === "interactive");
time("initTheme");
// Show deprecation warnings in interactive mode
if (isInteractive && deprecationWarnings.length > 0) {
if (appMode === "interactive" && deprecationWarnings.length > 0) {
await showDeprecationWarnings(deprecationWarnings);
}
let scopedModels: ScopedModel[] = [];
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
if (modelPatterns && modelPatterns.length > 0) {
scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
}
const scopedModels = [...session.scopedModels];
time("resolveModelScope");
// Create session manager based on CLI flags
let sessionManager = await createSessionManager(parsed, cwd, extensionsResult, settingsManager);
time("createSessionManager");
// Handle --resume: show session picker
if (parsed.resume) {
// Compute effective session dir for resume (same logic as createSessionManager)
const effectiveSessionDir =
parsed.sessionDir ??
settingsManager.getSessionDir() ??
(await callSessionDirectoryHook(extensionsResult, cwd));
const selectedPath = await selectSession(
(onProgress) => SessionManager.list(cwd, effectiveSessionDir, onProgress),
SessionManager.listAll,
);
if (!selectedPath) {
console.log(chalk.dim("No session selected"));
stopThemeWatcher();
process.exit(0);
}
sessionManager = SessionManager.open(selectedPath, effectiveSessionDir);
reportDiagnostics(runtime.diagnostics);
if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
process.exit(1);
}
const { options: sessionOptions, cliThinkingFromModel } = buildSessionOptions(
parsed,
scopedModels,
sessionManager,
modelRegistry,
settingsManager,
);
if (parsed.apiKey) {
if (!sessionOptions.model) {
console.error(
chalk.red("--api-key requires a model to be specified via --model, --provider/--model, or --models"),
);
process.exit(1);
}
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
}
const runtimeBootstrap = buildRuntimeBootstrap(parsed, cwd, agentDir, authStorage, sessionOptions);
const runtime = await createAgentSessionRuntime(runtimeBootstrap, {
cwd: sessionManager?.getCwd() ?? cwd,
sessionManager,
resourceLoader,
});
if (process.cwd() !== runtime.cwd) {
process.chdir(runtime.cwd);
}
const runtimeHost = new AgentSessionRuntimeHost(runtimeBootstrap, runtime);
const { session, modelFallbackMessage } = runtime;
time("createAgentSession");
if (!isInteractive && !session.model) {
if (appMode !== "interactive" && !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.");
@@ -890,25 +613,16 @@ export async function main(args: string[]) {
process.exit(1);
}
// Clamp thinking level to model capabilities for CLI-provided thinking levels.
// This covers both --thinking <level> and --model <pattern>:<thinking>.
const cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel;
if (session.model && cliThinkingOverride) {
let effectiveThinking = session.thinkingLevel;
if (!session.model.reasoning) {
effectiveThinking = "off";
} else if (effectiveThinking === "xhigh" && !supportsXhigh(session.model)) {
effectiveThinking = "high";
}
if (effectiveThinking !== session.thinkingLevel) {
session.setThinkingLevel(effectiveThinking);
}
const startupBenchmark = isTruthyEnvFlag(process.env.PI_STARTUP_BENCHMARK);
if (startupBenchmark && appMode !== "interactive") {
console.error(chalk.red("Error: PI_STARTUP_BENCHMARK only supports interactive mode"));
process.exit(1);
}
if (mode === "rpc") {
if (appMode === "rpc") {
printTimings();
await runRpcMode(runtimeHost);
} else if (isInteractive) {
await runRpcMode(runtime);
} else if (appMode === "interactive") {
if (scopedModels.length > 0 && (parsed.verbose || !settingsManager.getQuietStartup())) {
const modelList = scopedModels
.map((sm) => {
@@ -919,7 +633,7 @@ export async function main(args: string[]) {
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
}
const interactiveMode = new InteractiveMode(runtimeHost, {
const interactiveMode = new InteractiveMode(runtime, {
migratedProviders,
modelFallbackMessage,
initialMessage,
@@ -946,8 +660,8 @@ export async function main(args: string[]) {
await interactiveMode.run();
} else {
printTimings();
const exitCode = await runPrintMode(runtimeHost, {
mode,
const exitCode = await runPrintMode(runtime, {
mode: toPrintOutputMode(appMode),
messages: parsed.messages,
initialMessage,
initialImages,

View File

@@ -6,6 +6,7 @@ import chalk from "chalk";
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.js";
import { migrateKeybindingsConfig } from "./core/keybindings.js";
const MIGRATION_GUIDE_URL =
"https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
@@ -152,6 +153,23 @@ function migrateCommandsToPrompts(baseDir: string, label: string): boolean {
return false;
}
function migrateKeybindingsConfigFile(): void {
const configPath = join(getAgentDir(), "keybindings.json");
if (!existsSync(configPath)) return;
try {
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return;
}
const { config, migrated } = migrateKeybindingsConfig(parsed as Record<string, unknown>);
if (!migrated) return;
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
} catch {
// Ignore malformed files during migration
}
}
/**
* Move fd/rg binaries from tools/ to bin/ if they exist.
*/
@@ -290,6 +308,7 @@ export function runMigrations(cwd: string = process.cwd()): {
const migratedAuthProviders = migrateAuthToAuthJson();
migrateSessionsFromAgentRoot();
migrateToolsToBin();
migrateKeybindingsConfigFile();
const deprecationWarnings = migrateExtensionSystem(cwd);
return { migratedAuthProviders, deprecationWarnings };
}

View File

@@ -47,7 +47,7 @@ import {
VERSION,
} from "../../config.js";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.js";
import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js";
import type {
ExtensionContext,
ExtensionRunner,
@@ -107,6 +107,7 @@ import {
setRegisteredThemes,
setTheme,
setThemeInstance,
stopThemeWatcher,
Theme,
type ThemeColor,
theme,
@@ -145,7 +146,7 @@ export interface InteractiveModeOptions {
}
export class InteractiveMode {
private runtimeHost: AgentSessionRuntimeHost;
private runtimeHost: AgentSessionRuntime;
private ui: TUI;
private chatContainer: Container;
private pendingMessagesContainer: Container;
@@ -257,7 +258,7 @@ export class InteractiveMode {
}
constructor(
runtimeHost: AgentSessionRuntimeHost,
runtimeHost: AgentSessionRuntime,
private options: InteractiveModeOptions = {},
) {
this.runtimeHost = runtimeHost;
@@ -1177,23 +1178,31 @@ export class InteractiveMode {
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.ui.requestRender();
try {
const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.ui.requestRender();
}
return result;
} catch (error: unknown) {
return this.handleFatalRuntimeError("Failed to create session", error);
}
return result;
},
fork: async (entryId) => {
const result = await this.runtimeHost.fork(entryId);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session");
try {
const result = await this.runtimeHost.fork(entryId);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session");
}
return { cancelled: result.cancelled };
} catch (error: unknown) {
return this.handleFatalRuntimeError("Failed to fork session", error);
}
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
@@ -1275,6 +1284,14 @@ export class InteractiveMode {
this.updateTerminalTitle();
}
private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {
const message = error instanceof Error ? error.message : String(error);
this.showError(`${prefix}: ${message}`);
stopThemeWatcher();
this.stop();
process.exit(1);
}
private renderCurrentSessionState(): void {
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
@@ -3817,13 +3834,17 @@ export class InteractiveMode {
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
const result = await this.runtimeHost.switchSession(sessionPath);
if (result.cancelled) {
return;
try {
const result = await this.runtimeHost.switchSession(sessionPath);
if (result.cancelled) {
return;
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.showStatus("Resumed session");
} catch (error: unknown) {
await this.handleFatalRuntimeError("Failed to resume session", error);
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.showStatus("Resumed session");
}
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
@@ -4088,7 +4109,7 @@ export class InteractiveMode {
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
} catch (error: unknown) {
this.showError(`Failed to import session: ${error instanceof Error ? error.message : "Unknown error"}`);
await this.handleFatalRuntimeError("Failed to import session", error);
}
}
@@ -4432,15 +4453,19 @@ export class InteractiveMode {
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
const result = await this.runtimeHost.newSession();
if (result.cancelled) {
return;
try {
const result = await this.runtimeHost.newSession();
if (result.cancelled) {
return;
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
} catch (error: unknown) {
await this.handleFatalRuntimeError("Failed to create session", error);
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
}
private handleDebugCommand(): void {

View File

@@ -7,7 +7,7 @@
*/
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
import type { AgentSessionRuntimeHost } from "../core/agent-session-runtime.js";
import type { AgentSessionRuntime } from "../core/agent-session-runtime.js";
import { flushRawStdout, writeRawStdout } from "../core/output-guard.js";
/**
@@ -28,7 +28,7 @@ export interface PrintModeOptions {
* Run in print (single-shot) mode.
* Sends prompts to the agent and outputs the result.
*/
export async function runPrintMode(runtimeHost: AgentSessionRuntimeHost, options: PrintModeOptions): Promise<number> {
export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: PrintModeOptions): Promise<number> {
const { mode, messages = [], initialMessage, initialImages } = options;
let exitCode = 0;
let session = runtimeHost.session;
@@ -124,6 +124,9 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntimeHost, options
}
return exitCode;
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
} finally {
unsubscribe?.();
await runtimeHost.dispose();

View File

@@ -12,7 +12,7 @@
*/
import * as crypto from "node:crypto";
import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.js";
import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
@@ -43,7 +43,7 @@ export type {
* Run in RPC mode.
* Listens for JSON commands on stdin, outputs events and responses on stdout.
*/
export async function runRpcMode(runtimeHost: AgentSessionRuntimeHost): Promise<never> {
export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<never> {
takeOverStdout();
let session = runtimeHost.session;
let unsubscribe: (() => void) | undefined;
@@ -628,29 +628,49 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntimeHost): Promise<
}
const handleInputLine = async (line: string) => {
let parsed: unknown;
try {
const parsed = JSON.parse(line);
parsed = JSON.parse(line);
} catch (parseError: unknown) {
output(
error(
undefined,
"parse",
`Failed to parse command: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
),
);
return;
}
// Handle extension UI responses
if (parsed.type === "extension_ui_response") {
const response = parsed as RpcExtensionUIResponse;
const pending = pendingExtensionRequests.get(response.id);
if (pending) {
pendingExtensionRequests.delete(response.id);
pending.resolve(response);
}
return;
// Handle extension UI responses
if (
typeof parsed === "object" &&
parsed !== null &&
"type" in parsed &&
parsed.type === "extension_ui_response"
) {
const response = parsed as RpcExtensionUIResponse;
const pending = pendingExtensionRequests.get(response.id);
if (pending) {
pendingExtensionRequests.delete(response.id);
pending.resolve(response);
}
return;
}
// Handle regular commands
const command = parsed as RpcCommand;
const command = parsed as RpcCommand;
try {
const response = await handleCommand(command);
output(response);
// Check for deferred shutdown request (idle between commands)
await checkShutdownRequested();
} catch (e: any) {
output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
} catch (commandError: unknown) {
output(
error(
command.id,
command.type,
commandError instanceof Error ? commandError.message : String(commandError),
),
);
}
};

View File

@@ -0,0 +1,270 @@
import chalk from "chalk";
import { selectConfig } from "./cli/config-selector.js";
import { APP_NAME, getAgentDir } from "./config.js";
import { DefaultPackageManager } from "./core/package-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
export type PackageCommand = "install" | "remove" | "update" | "list";
interface PackageCommandOptions {
command: PackageCommand;
source?: string;
local: boolean;
help: boolean;
invalidOption?: string;
}
function reportSettingsErrors(settingsManager: SettingsManager, context: string): void {
const errors = settingsManager.drainErrors();
for (const { scope, error } of errors) {
console.error(chalk.yellow(`Warning (${context}, ${scope} settings): ${error.message}`));
if (error.stack) {
console.error(chalk.dim(error.stack));
}
}
}
function getPackageCommandUsage(command: PackageCommand): string {
switch (command) {
case "install":
return `${APP_NAME} install <source> [-l]`;
case "remove":
return `${APP_NAME} remove <source> [-l]`;
case "update":
return `${APP_NAME} update [source]`;
case "list":
return `${APP_NAME} list`;
}
}
function printPackageCommandHelp(command: PackageCommand): void {
switch (command) {
case "install":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("install")}
Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
Examples:
${APP_NAME} install npm:@foo/bar
${APP_NAME} install git:github.com/user/repo
${APP_NAME} install git:git@github.com:user/repo
${APP_NAME} install https://github.com/user/repo
${APP_NAME} install ssh://git@github.com/user/repo
${APP_NAME} install ./local/path
`);
return;
case "remove":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("remove")}
Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
Examples:
${APP_NAME} remove npm:@foo/bar
${APP_NAME} uninstall npm:@foo/bar
`);
return;
case "update":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("update")}
Update installed packages.
If <source> is provided, only that package is updated.
`);
return;
case "list":
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("list")}
List installed packages from user and project settings.
`);
return;
}
}
function parsePackageCommand(args: string[]): PackageCommandOptions | undefined {
const [rawCommand, ...rest] = args;
let command: PackageCommand | undefined;
if (rawCommand === "uninstall") {
command = "remove";
} else if (rawCommand === "install" || rawCommand === "remove" || rawCommand === "update" || rawCommand === "list") {
command = rawCommand;
}
if (!command) {
return undefined;
}
let local = false;
let help = false;
let invalidOption: string | undefined;
let source: string | undefined;
for (const arg of rest) {
if (arg === "-h" || arg === "--help") {
help = true;
continue;
}
if (arg === "-l" || arg === "--local") {
if (command === "install" || command === "remove") {
local = true;
} else {
invalidOption = invalidOption ?? arg;
}
continue;
}
if (arg.startsWith("-")) {
invalidOption = invalidOption ?? arg;
continue;
}
if (!source) {
source = arg;
}
}
return { command, source, local, help, invalidOption };
}
export async function handleConfigCommand(args: string[]): Promise<boolean> {
if (args[0] !== "config") {
return false;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve();
await selectConfig({
resolvedPaths,
settingsManager,
cwd,
agentDir,
});
process.exit(0);
}
export async function handlePackageCommand(args: string[]): Promise<boolean> {
const options = parsePackageCommand(args);
if (!options) {
return false;
}
if (options.help) {
printPackageCommandHelp(options.command);
return true;
}
if (options.invalidOption) {
console.error(chalk.red(`Unknown option ${options.invalidOption} for "${options.command}".`));
console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getPackageCommandUsage(options.command)}".`));
process.exitCode = 1;
return true;
}
const source = options.source;
if ((options.command === "install" || options.command === "remove") && !source) {
console.error(chalk.red(`Missing ${options.command} source.`));
console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`));
process.exitCode = 1;
return true;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager, "package command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
process.stdout.write(chalk.dim(`${event.message}\n`));
}
});
try {
switch (options.command) {
case "install":
await packageManager.installAndPersist(source!, { local: options.local });
console.log(chalk.green(`Installed ${source}`));
return true;
case "remove": {
const removed = await packageManager.removeAndPersist(source!, { local: options.local });
if (!removed) {
console.error(chalk.red(`No matching package found for ${source}`));
process.exitCode = 1;
return true;
}
console.log(chalk.green(`Removed ${source}`));
return true;
}
case "list": {
const configuredPackages = packageManager.listConfiguredPackages();
const userPackages = configuredPackages.filter((pkg) => pkg.scope === "user");
const projectPackages = configuredPackages.filter((pkg) => pkg.scope === "project");
if (configuredPackages.length === 0) {
console.log(chalk.dim("No packages installed."));
return true;
}
const formatPackage = (pkg: (typeof configuredPackages)[number]) => {
const display = pkg.filtered ? `${pkg.source} (filtered)` : pkg.source;
console.log(` ${display}`);
if (pkg.installedPath) {
console.log(chalk.dim(` ${pkg.installedPath}`));
}
};
if (userPackages.length > 0) {
console.log(chalk.bold("User packages:"));
for (const pkg of userPackages) {
formatPackage(pkg);
}
}
if (projectPackages.length > 0) {
if (userPackages.length > 0) console.log();
console.log(chalk.bold("Project packages:"));
for (const pkg of projectPackages) {
formatPackage(pkg);
}
}
return true;
}
case "update":
await packageManager.update(source);
if (source) {
console.log(chalk.green(`Updated ${source}`));
} else {
console.log(chalk.green("Updated packages"));
}
return true;
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown package command error";
console.error(chalk.red(`Error: ${message}`));
process.exitCode = 1;
return true;
}
}

View File

@@ -14,9 +14,11 @@ import { getModel } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { AgentSession } from "../src/core/agent-session.js";
import {
type AgentSessionRuntimeHost,
type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
AgentSessionRuntimeHost as RuntimeHost,
createAgentSessionServices,
} from "../src/core/agent-session-runtime.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import { SessionManager } from "../src/core/session-manager.js";
@@ -25,7 +27,7 @@ import { API_KEY } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession forking", () => {
let session: AgentSession;
let runtimeHost: AgentSessionRuntimeHost;
let runtimeHost: AgentSessionRuntime;
let tempDir: string;
let sessionManager: SessionManager;
@@ -50,23 +52,38 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", API_KEY!);
const bootstrap = {
const servicesOptions = {
agentDir: tempDir,
authStorage,
model,
tools: codingTools,
resourceLoader: {
resourceLoaderOptions: {
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...servicesOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model,
tools: codingTools,
})),
services,
diagnostics: services.diagnostics,
};
};
runtimeHost = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager,
});
runtimeHost = new RuntimeHost(bootstrap, runtime);
session = runtimeHost.session;
session.subscribe(() => {});
return session;

View File

@@ -4,9 +4,10 @@ import { join } from "node:path";
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../src/core/agent-session-runtime.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import { SessionManager } from "../src/core/session-manager.js";
@@ -19,7 +20,7 @@ import type {
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
describe("AgentSessionRuntimeHost session lifecycle events", () => {
describe("AgentSessionRuntime session lifecycle events", () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
@@ -38,22 +39,38 @@ describe("AgentSessionRuntimeHost session lifecycle events", () => {
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const bootstrap: AgentSessionRuntimeBootstrap = {
const runtimeOptions = {
agentDir: tempDir,
authStorage,
model: faux.getModel(),
resourceLoader: {
resourceLoaderOptions: {
extensionFactories: [extensionFactory],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...runtimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: faux.getModel(),
})),
services,
diagnostics: services.diagnostics,
};
};
const runtimeHost = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.create(tempDir),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runtimeHost.session.bindExtensions({});
cleanups.push(async () => {

View File

@@ -270,9 +270,20 @@ describe("parseArgs", () => {
expect(result.messages).toEqual(["explain this"]);
});
test("ignores unknown flags starting with -", () => {
test("captures unknown long flags with string values", () => {
const result = parseArgs(["--unknown-flag", "message"]);
expect(result.messages).toEqual(["message"]);
expect(result.messages).toEqual([]);
expect(result.unknownFlags.get("unknown-flag")).toBe("message");
});
test("captures unknown boolean long flags", () => {
const result = parseArgs(["--unknown-flag"]);
expect(result.unknownFlags.get("unknown-flag")).toBe(true);
});
test("captures unknown long flags with equals syntax", () => {
const result = parseArgs(["--unknown-flag=value"]);
expect(result.unknownFlags.get("unknown-flag")).toBe("value");
});
});

View File

@@ -7,6 +7,7 @@ function createArgs(messages: string[] = []): Args {
messages: [...messages],
fileArgs: [],
unknownFlags: new Map(),
diagnostics: [],
};
}

View File

@@ -2,7 +2,9 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { KeybindingsManager, migrateKeybindingsConfigFile } from "../src/core/keybindings.js";
import { ENV_AGENT_DIR } from "../src/config.js";
import { KeybindingsManager } from "../src/core/keybindings.js";
import { runMigrations } from "../src/migrations.js";
describe("keybindings migration", () => {
const tempDirs: string[] = [];
@@ -25,8 +27,14 @@ describe("keybindings migration", () => {
cursorUp: ["up", "ctrl+p"],
expandTools: "ctrl+x",
});
expect(migrateKeybindingsConfigFile(agentDir)).toBe(true);
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
runMigrations(agentDir);
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record<
string,
@@ -43,8 +51,14 @@ describe("keybindings migration", () => {
expandTools: "ctrl+x",
"app.tools.expand": "ctrl+y",
});
expect(migrateKeybindingsConfigFile(agentDir)).toBe(true);
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
runMigrations(agentDir);
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record<
string,

View File

@@ -1,158 +0,0 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.js";
const mocks = vi.hoisted(() => ({
state: {
hookSessionDir: undefined as string | undefined,
capturedSessionDir: undefined as string | undefined,
},
createAgentSession: vi.fn(async (options: { sessionManager?: { getSessionDir(): string } }) => {
mocks.state.capturedSessionDir = options.sessionManager?.getSessionDir();
return {
session: {
model: { id: "test-model", provider: "test", reasoning: false },
thinkingLevel: "off",
setThinkingLevel: vi.fn(),
},
modelFallbackMessage: undefined,
};
}),
runPrintMode: vi.fn(async () => 0),
selectSession: vi.fn(),
}));
vi.mock("../src/core/sdk.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
createAgentSession: mocks.createAgentSession,
};
});
vi.mock("../src/modes/index.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
runPrintMode: mocks.runPrintMode,
};
});
vi.mock("../src/cli/session-picker.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
selectSession: mocks.selectSession,
};
});
vi.mock("../src/core/resource-loader.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
DefaultResourceLoader: class {
async reload(): Promise<void> {}
getExtensions() {
const handlers = new Map();
if (mocks.state.hookSessionDir) {
handlers.set("session_directory", [async () => ({ sessionDir: mocks.state.hookSessionDir })]);
}
return {
extensions: [{ path: "/mock-extension.ts", handlers, flags: new Map() }],
errors: [],
runtime: {
pendingProviderRegistrations: [],
flagValues: new Map(),
},
};
}
},
};
});
describe("sessionDir precedence", () => {
let tempDir: string;
let agentDir: string;
let projectDir: string;
let originalCwd: string;
let originalAgentDir: string | undefined;
let originalExitCode: typeof process.exitCode;
let originalIsTTY: boolean | undefined;
beforeEach(() => {
vi.resetModules();
mocks.state.hookSessionDir = "./hook-sessions";
mocks.state.capturedSessionDir = undefined;
mocks.createAgentSession.mockClear();
mocks.runPrintMode.mockClear();
mocks.selectSession.mockReset();
tempDir = join(tmpdir(), `pi-session-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
projectDir = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(join(projectDir, ".pi"), { recursive: true });
originalCwd = process.cwd();
originalAgentDir = process.env[ENV_AGENT_DIR];
originalExitCode = process.exitCode;
originalIsTTY = process.stdin.isTTY;
process.exitCode = undefined;
process.env[ENV_AGENT_DIR] = agentDir;
process.chdir(projectDir);
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
});
afterEach(() => {
process.chdir(originalCwd);
process.exitCode = originalExitCode;
Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true });
if (originalAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = originalAgentDir;
}
rmSync(tempDir, { recursive: true, force: true });
});
it("prefers settings sessionDir over the session_directory hook for new sessions", async () => {
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" }));
const { main } = await import("../src/main.js");
await main(["--print", "test prompt"]);
expect(mocks.state.capturedSessionDir).toBe("./settings-sessions");
expect(mocks.runPrintMode).toHaveBeenCalledOnce();
});
it("prefers CLI --session-dir over settings and the session_directory hook", async () => {
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" }));
const { main } = await import("../src/main.js");
await main(["--print", "--session-dir", "./cli-sessions", "test prompt"]);
expect(mocks.state.capturedSessionDir).toBe("./cli-sessions");
expect(mocks.runPrintMode).toHaveBeenCalledOnce();
});
it("uses settings sessionDir ahead of the session_directory hook for --resume", async () => {
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" }));
const { SessionManager } = await import("../src/core/session-manager.js");
const listSpy = vi.spyOn(SessionManager, "list");
mocks.selectSession.mockImplementation(async (listCurrent: (onProgress: () => void) => Promise<unknown>) => {
await listCurrent(() => {});
return join(projectDir, "picked-session.jsonl");
});
const { main } = await import("../src/main.js");
await main(["--print", "--resume"]);
expect(listSpy).toHaveBeenCalledWith(expect.any(String), "./settings-sessions", expect.any(Function));
expect(mocks.state.capturedSessionDir).toBe("./settings-sessions");
expect(mocks.runPrintMode).toHaveBeenCalledOnce();
});
});

View File

@@ -155,7 +155,7 @@ describe("SettingsManager", () => {
});
describe("reload", () => {
it("should reload global settings from disk", () => {
it("should reload global settings from disk", async () => {
const settingsPath = join(agentDir, "settings.json");
writeFileSync(
settingsPath,
@@ -176,21 +176,21 @@ describe("SettingsManager", () => {
}),
);
manager.reload();
await manager.reload();
expect(manager.getTheme()).toBe("light");
expect(manager.getExtensionPaths()).toEqual(["/after.ts"]);
expect(manager.getDefaultModel()).toBe("claude-sonnet");
});
it("should keep previous settings when file is invalid", () => {
it("should keep previous settings when file is invalid", async () => {
const settingsPath = join(agentDir, "settings.json");
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
const manager = SettingsManager.create(projectDir, agentDir);
writeFileSync(settingsPath, "{ invalid json");
manager.reload();
await manager.reload();
expect(manager.getTheme()).toBe("dark");
});

View File

@@ -1,6 +1,6 @@
# Coding agent suite tests
Use `test/suite/` for the new harness-based test suite around `AgentSession` and `AgentSessionRuntimeHost`.
Use `test/suite/` for the new harness-based test suite around `AgentSession` and `AgentSessionRuntime`.
Rules:
- Use `test/suite/harness.ts`

View File

@@ -4,13 +4,15 @@ import { join } from "node:path";
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../src/core/agent-session-runtime.js";
import { AuthStorage } from "../../src/core/auth-storage.js";
import { SessionManager } from "../../src/core/session-manager.js";
import type {
ExtensionAPI,
ExtensionFactory,
SessionBeforeForkEvent,
SessionBeforeSwitchEvent,
@@ -19,7 +21,7 @@ import type {
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
describe("AgentSessionRuntimeHost characterization", () => {
describe("AgentSessionRuntime characterization", () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
@@ -48,14 +50,14 @@ describe("AgentSessionRuntimeHost characterization", () => {
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const bootstrap: AgentSessionRuntimeBootstrap = {
const runtimeOptions = {
agentDir: tempDir,
authStorage,
model: options?.bootstrapModel === false ? undefined : faux.getModel(),
thinkingLevel: options?.bootstrapThinkingLevel === false ? undefined : undefined,
resourceLoader: {
resourceLoaderOptions: {
extensionFactories: [
(pi) => {
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
@@ -79,11 +81,28 @@ describe("AgentSessionRuntimeHost characterization", () => {
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...runtimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: runtimeOptions.model,
thinkingLevel: runtimeOptions.thinkingLevel,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtimeHost = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.create(tempDir),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runtimeHost.session.bindExtensions({});
cleanups.push(async () => {
@@ -99,7 +118,7 @@ describe("AgentSessionRuntimeHost characterization", () => {
it("emits session_before_switch and session_start for new and resume flows", async () => {
const events: RecordedSessionEvent[] = [];
const { runtimeHost } = await createRuntimeHost((pi) => {
const { runtimeHost } = await createRuntimeHost((pi: ExtensionAPI) => {
pi.on("session_before_switch", (event) => {
events.push(event);
});
@@ -140,7 +159,7 @@ describe("AgentSessionRuntimeHost characterization", () => {
it("honors session_before_switch cancellation for new and resume", async () => {
const events: RecordedSessionEvent[] = [];
let cancelReason: "new" | "resume" | undefined;
const { runtimeHost } = await createRuntimeHost((pi) => {
const { runtimeHost } = await createRuntimeHost((pi: ExtensionAPI) => {
pi.on("session_before_switch", (event) => {
events.push(event);
if (event.reason === cancelReason) {
@@ -175,7 +194,7 @@ describe("AgentSessionRuntimeHost characterization", () => {
it("emits session_before_fork and session_start and honors cancellation", async () => {
const events: RecordedSessionEvent[] = [];
let cancelNextFork = false;
const { runtimeHost } = await createRuntimeHost((pi) => {
const { runtimeHost } = await createRuntimeHost((pi: ExtensionAPI) => {
pi.on("session_before_fork", (event) => {
events.push(event);
if (cancelNextFork) {
@@ -222,39 +241,60 @@ describe("AgentSessionRuntimeHost characterization", () => {
const { runtimeHost, faux, tempDir } = await createRuntimeHost(() => {}, { cwd: firstDir });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const otherRuntime = await createAgentSessionRuntime(
{
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoader: {
extensionFactories: [
(pi) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
{ cwd: secondDir, sessionManager: SessionManager.create(secondDir) },
);
};
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({
...otherRuntimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
cwd: secondDir,
agentDir: tempDir,
sessionManager: SessionManager.create(secondDir),
});
cleanups.push(async () => {
otherRuntime.session.dispose();
await otherRuntime.dispose();
});
await otherRuntime.session.prompt("other");
const otherSessionFile = otherRuntime.session.sessionFile!;
@@ -274,39 +314,60 @@ describe("AgentSessionRuntimeHost characterization", () => {
mkdirSync(otherDir, { recursive: true });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const otherRuntime = await createAgentSessionRuntime(
{
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoader: {
extensionFactories: [
(pi) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
{ cwd: otherDir, sessionManager: SessionManager.create(otherDir) },
);
};
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({
...otherRuntimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
cwd: otherDir,
agentDir: tempDir,
sessionManager: SessionManager.create(otherDir),
});
cleanups.push(async () => {
otherRuntime.session.dispose();
await otherRuntime.dispose();
});
await otherRuntime.session.setModel(faux.getModel("faux-2")!);
otherRuntime.session.setThinkingLevel("off");

View File

@@ -0,0 +1,100 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../../src/core/agent-session-runtime.js";
import { AuthStorage } from "../../../src/core/auth-storage.js";
import { SessionManager } from "../../../src/core/session-manager.js";
describe("issue #2753 reload stale resource settings", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
});
it("applies updated top-level prompt settings on reload after startup", async () => {
const tempDir = join(tmpdir(), `pi-2753-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const agentDir = join(tempDir, "agent");
const promptsDir = join(agentDir, "prompts");
mkdirSync(promptsDir, { recursive: true });
writeFileSync(join(promptsDir, "test.md"), "Echo test prompt\n");
const faux = registerFauxProvider({
models: [{ id: "faux-1", reasoning: false }],
});
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noThemes: true,
},
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: faux.getModel(),
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir,
sessionManager: SessionManager.create(tempDir),
});
cleanups.push(() => {
runtime.session.dispose();
faux.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).toContain("test");
writeFileSync(join(agentDir, "settings.json"), `${JSON.stringify({ prompts: ["-prompts/test.md"] }, null, 2)}\n`);
await runtime.session.reload();
expect(runtime.services.settingsManager.getGlobalSettings().prompts).toEqual(["-prompts/test.md"]);
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).not.toContain("test");
});
});