From 9f9277ccdd2f652be5608c27e2f19f6b93ab980e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 3 Apr 2026 20:14:12 +0200 Subject: [PATCH] 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 --- packages/ai/src/models.generated.ts | 399 ++++++--- packages/coding-agent/CHANGELOG.md | 72 +- packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/extensions.md | 28 +- packages/coding-agent/docs/sdk.md | 134 ++- packages/coding-agent/docs/settings.md | 2 +- .../examples/sdk/13-session-runtime.ts | 42 +- packages/coding-agent/examples/sdk/README.md | 2 + packages/coding-agent/src/cli/args.ts | 57 +- .../src/core/agent-session-runtime.ts | 398 ++++----- .../src/core/agent-session-services.ts | 197 +++++ .../coding-agent/src/core/agent-session.ts | 2 +- .../coding-agent/src/core/extensions/index.ts | 3 - .../coding-agent/src/core/extensions/types.ts | 18 - packages/coding-agent/src/core/index.ts | 14 +- packages/coding-agent/src/core/keybindings.ts | 18 +- .../coding-agent/src/core/package-manager.ts | 48 ++ .../coding-agent/src/core/resource-loader.ts | 21 + packages/coding-agent/src/core/sdk.ts | 7 +- .../coding-agent/src/core/settings-manager.ts | 3 +- packages/coding-agent/src/index.ts | 12 +- .../coding-agent/src/main-package-command.ts | 247 ++++++ packages/coding-agent/src/main.ts | 800 ++++++------------ packages/coding-agent/src/migrations.ts | 19 + .../src/modes/interactive/interactive-mode.ts | 87 +- packages/coding-agent/src/modes/print-mode.ts | 7 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 56 +- .../coding-agent/src/package-manager-cli.ts | 270 ++++++ .../test/agent-session-branching.test.ts | 35 +- .../test/agent-session-runtime-events.test.ts | 31 +- packages/coding-agent/test/args.test.ts | 15 +- .../coding-agent/test/initial-message.test.ts | 1 + .../test/keybindings-migration.test.ts | 24 +- .../test/session-dir-precedence.test.ts | 158 ---- .../test/settings-manager.test.ts | 8 +- packages/coding-agent/test/suite/README.md | 2 +- .../suite/agent-session-runtime-host.test.ts | 207 +++-- ...753-reload-stale-resource-settings.test.ts | 100 +++ 38 files changed, 2180 insertions(+), 1366 deletions(-) create mode 100644 packages/coding-agent/src/core/agent-session-services.ts create mode 100644 packages/coding-agent/src/main-package-command.ts create mode 100644 packages/coding-agent/src/package-manager-cli.ts delete mode 100644 packages/coding-agent/test/session-dir-precedence.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index eac79b85..6ddb6d5b 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -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; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e316e96d..bdf44528 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 65659a87..3274557d 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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/). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index e47e04e5..5d53f4da 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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 diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a4a66e47..5b5e0f67 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -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 diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 5ebf949f..1405d61c 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 diff --git a/packages/coding-agent/examples/sdk/13-session-runtime.ts b/packages/coding-agent/examples/sdk/13-session-runtime.ts index 72e5a82e..1567ebb8 100644 --- a/packages/coding-agent/examples/sdk/13-session-runtime.ts +++ b/packages/coding-agent/examples/sdk/13-session-runtime.ts @@ -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(); diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index d828cda2..b28c4e67 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -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 | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index a630e862..186a4371 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -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; + 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): 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 0 + ? `\n${chalk.bold("Extension CLI Flags:")}\n${extensionFlags + .map((flag) => { + const value = flag.type === "string" ? " " : ""; + 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 diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index 6b59335a..7e064ea8 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -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; - /** Initial thinking level for the first created session runtime. */ - thinkingLevel?: ThinkingLevel; - /** Optional scoped model list for model cycling and selection. */ - scopedModels?: Array<{ model: Model; 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) - | Omit; +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 { - 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; 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 { - 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 { + 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; }): 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 { - 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 { + 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"; diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts new file mode 100644 index 00000000..e6f9843b --- /dev/null +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -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; + resourceLoaderOptions?: Omit; +} + +/** + * 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; + thinkingLevel?: ThinkingLevel; + scopedModels?: Array<{ model: Model; 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 | undefined, +): AgentSessionRuntimeDiagnostic[] { + if (!extensionFlagValues) { + return []; + } + + const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + const extensionsResult = resourceLoader.getExtensions(); + const registeredFlags = new Map(); + 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 { + 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 { + 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, + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f3a5b753..53ceca3f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2350,7 +2350,7 @@ export class AgentSession { async reload(): Promise { 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({ diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index ff8e4a4a..cca8c914 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -116,9 +116,6 @@ export type { SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, - SessionDirectoryEvent, - SessionDirectoryHandler, - SessionDirectoryResult, SessionEvent, SessionShutdownEvent, // Events - Session diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index b5bef944..2c20469f 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; - export interface SessionBeforeSwitchResult { cancel?: boolean; } @@ -1006,7 +989,6 @@ export interface ExtensionAPI { // ========================================================================= on(event: "resources_discover", handler: ExtensionHandler): void; - on(event: "session_directory", handler: SessionDirectoryHandler): void; on(event: "session_start", handler: ExtensionHandler): void; on( event: "session_before_switch", diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 21ab5ca5..17533f33 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -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"; diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 3baa7290..a27d32cb 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -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): { +export function migrateKeybindingsConfig(rawConfig: Record): { config: Record; migrated: boolean; } { @@ -269,18 +269,6 @@ function loadRawConfig(path: string): Record | 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); } } diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index b8c599cf..5890ec6c 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -57,11 +57,21 @@ export interface PackageUpdate { scope: Exclude; } +export interface ConfiguredPackage { + source: string; + scope: "user" | "project"; + filtered: boolean; + installedPath?: string; +} + export interface PackageManager { resolve(onMissing?: (source: string) => Promise): Promise; install(source: string, options?: { local?: boolean }): Promise; + installAndPersist(source: string, options?: { local?: boolean }): Promise; remove(source: string, options?: { local?: boolean }): Promise; + removeAndPersist(source: string, options?: { local?: boolean }): Promise; update(source?: string): Promise; + 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 { 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 { + await this.install(source, options); + this.addSourceToSettings(source, options); + } + async remove(source: string, options?: { local?: boolean }): Promise { 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 { + await this.remove(source, options); + return this.removeSourceFromSettings(source, options); + } + async update(source?: string): Promise { const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 13d9f35b..095cf870 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -315,6 +315,7 @@ export class DefaultResourceLoader implements ResourceLoader { } async reload(): Promise { + 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; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 0dfafa9b..7e4512cb 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -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, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index b94fa99b..fdd9a282 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -359,7 +359,8 @@ export class SettingsManager { return structuredClone(this.projectSettings); } - reload(): void { + async reload(): Promise { + await this.writeQueue; const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); if (!globalLoad.error) { this.globalSettings = globalLoad.settings; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index d9a159cd..5b0ba08a 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -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, diff --git a/packages/coding-agent/src/main-package-command.ts b/packages/coding-agent/src/main-package-command.ts new file mode 100644 index 00000000..7ab30ec6 --- /dev/null +++ b/packages/coding-agent/src/main-package-command.ts @@ -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 [-l]`; + case "remove": + return `${APP_NAME} remove [-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 [-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 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 { + 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; + } +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 64ab41c8..da6d9f5c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 { }); } -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 [-l]`; - case "remove": - return `${APP_NAME} remove [-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 [-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 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 { - 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 { + return appMode === "json" ? "json" : "text"; } async function prepareInitialMessage( @@ -389,32 +174,6 @@ async function promptConfirm(message: string): Promise { }); } -/** Helper to call CLI-only session_directory handlers before the initial session manager is created */ -async function callSessionDirectoryHook(extensions: LoadExtensionsResult, cwd: string): Promise { - 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 { +): Promise { 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 --model // - supports --model / @@ -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 { - 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(); - 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 and --model :. - 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, diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 2721ed8d..fef5eb64 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -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); + 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 }; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 014e85be..2701429e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 { + 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 { @@ -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 { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 1babc39e..235bffa7 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -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 { +export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: PrintModeOptions): Promise { 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(); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index e00aa64d..be562e69 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -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 { +export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { 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), + ), + ); } }; diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts new file mode 100644 index 00000000..d16f225d --- /dev/null +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -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 [-l]`; + case "remove": + return `${APP_NAME} remove [-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 [-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 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 { + 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 { + 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; + } +} diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index c7892d9d..128699ea 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -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; diff --git a/packages/coding-agent/test/agent-session-runtime-events.test.ts b/packages/coding-agent/test/agent-session-runtime-events.test.ts index 6a2410d1..3413332d 100644 --- a/packages/coding-agent/test/agent-session-runtime-events.test.ts +++ b/packages/coding-agent/test/agent-session-runtime-events.test.ts @@ -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> = []; 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 () => { diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 5e2e53be..74b72469 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -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"); }); }); diff --git a/packages/coding-agent/test/initial-message.test.ts b/packages/coding-agent/test/initial-message.test.ts index d8123bf4..22b1612d 100644 --- a/packages/coding-agent/test/initial-message.test.ts +++ b/packages/coding-agent/test/initial-message.test.ts @@ -7,6 +7,7 @@ function createArgs(messages: string[] = []): Args { messages: [...messages], fileArgs: [], unknownFlags: new Map(), + diagnostics: [], }; } diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts index 8db89c66..0799ff25 100644 --- a/packages/coding-agent/test/keybindings-migration.test.ts +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -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, diff --git a/packages/coding-agent/test/session-dir-precedence.test.ts b/packages/coding-agent/test/session-dir-precedence.test.ts deleted file mode 100644 index b017e0da..00000000 --- a/packages/coding-agent/test/session-dir-precedence.test.ts +++ /dev/null @@ -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; - return { - ...actual, - createAgentSession: mocks.createAgentSession, - }; -}); - -vi.mock("../src/modes/index.js", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - runPrintMode: mocks.runPrintMode, - }; -}); - -vi.mock("../src/cli/session-picker.js", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - selectSession: mocks.selectSession, - }; -}); - -vi.mock("../src/core/resource-loader.js", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - DefaultResourceLoader: class { - async reload(): Promise {} - - 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) => { - 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(); - }); -}); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 029ee448..2461b601 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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"); }); diff --git a/packages/coding-agent/test/suite/README.md b/packages/coding-agent/test/suite/README.md index 6017634d..cdd25a97 100644 --- a/packages/coding-agent/test/suite/README.md +++ b/packages/coding-agent/test/suite/README.md @@ -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` diff --git a/packages/coding-agent/test/suite/agent-session-runtime-host.test.ts b/packages/coding-agent/test/suite/agent-session-runtime-host.test.ts index b28628c5..2a61917d 100644 --- a/packages/coding-agent/test/suite/agent-session-runtime-host.test.ts +++ b/packages/coding-agent/test/suite/agent-session-runtime-host.test.ts @@ -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> = []; 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"); diff --git a/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts b/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts new file mode 100644 index 00000000..38748215 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts @@ -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"); + }); +});