chore: merge main into async-file-tools

This commit is contained in:
Armin Ronacher
2026-05-22 18:04:16 +02:00
Unverified
81 changed files with 1768 additions and 1156 deletions
+2
View File
@@ -2,6 +2,8 @@
## [Unreleased]
## [0.75.4] - 2026-05-20
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.75.3",
"version": "0.75.4",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@@ -29,7 +29,7 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.75.3",
"@earendil-works/pi-ai": "^0.75.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
+16
View File
@@ -2,11 +2,27 @@
## [Unreleased]
### Breaking Changes
- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks.
### Fixed
- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard.
- Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)).
- Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)).
## [0.75.4] - 2026-05-20
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources.
### Added
- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration.
### Fixed
- Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)).
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.75.3",
"version": "0.75.4",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
@@ -69,6 +69,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@smithy/node-http-handler": "4.7.3",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.1",
"http-proxy-agent": "7.0.2",
+14
View File
@@ -42,9 +42,23 @@ async function login(providerId: OAuthProviderId): Promise<void> {
if (info.instructions) console.log(info.instructions);
console.log();
},
onDeviceCode: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`);
console.log(`Enter code: ${info.userCode}`);
console.log();
},
onPrompt: async (p) => {
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
onSelect: async (p) => {
console.log(`\n${p.message}`);
for (let i = 0; i < p.options.length; i++) {
console.log(` ${i + 1}. ${p.options[i].label}`);
}
const choice = await promptFn(`Enter number (1-${p.options.length}):`);
const index = parseInt(choice, 10) - 1;
return p.options[index]?.id;
},
onProgress: (msg) => console.log(msg),
});
+1
View File
@@ -32,6 +32,7 @@ export * from "./utils/json-parse.ts";
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProvider,
+234 -416
View File
@@ -3278,6 +3278,42 @@ export const MODELS = {
contextWindow: 256000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"@cf/ibm-granite/granite-4.0-h-micro": {
id: "@cf/ibm-granite/granite-4.0-h-micro",
name: "Granite 4.0 H Micro",
api: "openai-completions",
provider: "cloudflare-workers-ai",
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1",
compat: {"sendSessionAffinityHeaders":true},
reasoning: false,
input: ["text"],
cost: {
input: 0.017,
output: 0.112,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131000,
maxTokens: 131000,
} satisfies Model<"openai-completions">,
"@cf/meta/llama-3.3-70b-instruct-fp8-fast": {
id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
name: "Llama 3.3 70B Instruct fp8 Fast",
api: "openai-completions",
provider: "cloudflare-workers-ai",
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1",
compat: {"sendSessionAffinityHeaders":true},
reasoning: false,
input: ["text"],
cost: {
input: 0.293,
output: 2.253,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 24000,
maxTokens: 24000,
} satisfies Model<"openai-completions">,
"@cf/meta/llama-4-scout-17b-16e-instruct": {
id: "@cf/meta/llama-4-scout-17b-16e-instruct",
name: "Llama 4 Scout 17B 16E Instruct",
@@ -3293,9 +3329,27 @@ export const MODELS = {
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
contextWindow: 131000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"@cf/mistralai/mistral-small-3.1-24b-instruct": {
id: "@cf/mistralai/mistral-small-3.1-24b-instruct",
name: "Mistral Small 3.1 24B Instruct",
api: "openai-completions",
provider: "cloudflare-workers-ai",
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1",
compat: {"sendSessionAffinityHeaders":true},
reasoning: false,
input: ["text"],
cost: {
input: 0.351,
output: 0.555,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"@cf/moonshotai/kimi-k2.5": {
id: "@cf/moonshotai/kimi-k2.5",
name: "Kimi K2.5",
@@ -3329,7 +3383,7 @@ export const MODELS = {
cacheRead: 0.16,
cacheWrite: 0,
},
contextWindow: 256000,
contextWindow: 262144,
maxTokens: 256000,
} satisfies Model<"openai-completions">,
"@cf/nvidia/nemotron-3-120b-a12b": {
@@ -3386,6 +3440,24 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"@cf/qwen/qwen3-30b-a3b-fp8": {
id: "@cf/qwen/qwen3-30b-a3b-fp8",
name: "Qwen3 30B A3b fp8",
api: "openai-completions",
provider: "cloudflare-workers-ai",
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1",
compat: {"sendSessionAffinityHeaders":true},
reasoning: true,
input: ["text"],
cost: {
input: 0.0509,
output: 0.335,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 32768,
} satisfies Model<"openai-completions">,
"@cf/zai-org/glm-4.7-flash": {
id: "@cf/zai-org/glm-4.7-flash",
name: "GLM-4.7-Flash",
@@ -3396,7 +3468,7 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.06,
input: 0.0605,
output: 0.4,
cacheRead: 0,
cacheWrite: 0,
@@ -4169,57 +4241,6 @@ export const MODELS = {
} satisfies Model<"openai-completions">,
},
"google": {
"gemini-1.5-flash": {
id: "gemini-1.5-flash",
name: "Gemini 1.5 Flash",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.075,
output: 0.3,
cacheRead: 0.01875,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 8192,
} satisfies Model<"google-generative-ai">,
"gemini-1.5-flash-8b": {
id: "gemini-1.5-flash-8b",
name: "Gemini 1.5 Flash-8B",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.0375,
output: 0.15,
cacheRead: 0.01,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 8192,
} satisfies Model<"google-generative-ai">,
"gemini-1.5-pro": {
id: "gemini-1.5-pro",
name: "Gemini 1.5 Pro",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: false,
input: ["text", "image"],
cost: {
input: 1.25,
output: 5,
cacheRead: 0.3125,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 8192,
} satisfies Model<"google-generative-ai">,
"gemini-2.0-flash": {
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
@@ -4239,7 +4260,7 @@ export const MODELS = {
} satisfies Model<"google-generative-ai">,
"gemini-2.0-flash-lite": {
id: "gemini-2.0-flash-lite",
name: "Gemini 2.0 Flash Lite",
name: "Gemini 2.0 Flash-Lite",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
@@ -4273,7 +4294,7 @@ export const MODELS = {
} satisfies Model<"google-generative-ai">,
"gemini-2.5-flash-lite": {
id: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash Lite",
name: "Gemini 2.5 Flash-Lite",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
@@ -4288,91 +4309,6 @@ export const MODELS = {
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-flash-lite-preview-06-17": {
id: "gemini-2.5-flash-lite-preview-06-17",
name: "Gemini 2.5 Flash Lite Preview 06-17",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.1,
output: 0.4,
cacheRead: 0.025,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-flash-lite-preview-09-2025": {
id: "gemini-2.5-flash-lite-preview-09-2025",
name: "Gemini 2.5 Flash Lite Preview 09-25",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.1,
output: 0.4,
cacheRead: 0.025,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-flash-preview-04-17": {
id: "gemini-2.5-flash-preview-04-17",
name: "Gemini 2.5 Flash Preview 04-17",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.15,
output: 0.6,
cacheRead: 0.0375,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-flash-preview-05-20": {
id: "gemini-2.5-flash-preview-05-20",
name: "Gemini 2.5 Flash Preview 05-20",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.15,
output: 0.6,
cacheRead: 0.0375,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-flash-preview-09-2025": {
id: "gemini-2.5-flash-preview-09-2025",
name: "Gemini 2.5 Flash Preview 09-25",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.3,
output: 2.5,
cacheRead: 0.075,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-pro": {
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
@@ -4390,40 +4326,6 @@ export const MODELS = {
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-pro-preview-05-06": {
id: "gemini-2.5-pro-preview-05-06",
name: "Gemini 2.5 Pro Preview 05-06",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 1.25,
output: 10,
cacheRead: 0.31,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-2.5-pro-preview-06-05": {
id: "gemini-2.5-pro-preview-06-05",
name: "Gemini 2.5 Pro Preview 06-05",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 1.25,
output: 10,
cacheRead: 0.31,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-3-flash-preview": {
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",
@@ -4457,8 +4359,8 @@ export const MODELS = {
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 64000,
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-3.1-flash-lite": {
id: "gemini-3.1-flash-lite",
@@ -4584,60 +4486,9 @@ export const MODELS = {
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-live-2.5-flash": {
id: "gemini-live-2.5-flash",
name: "Gemini Live 2.5 Flash",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.5,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 8000,
} satisfies Model<"google-generative-ai">,
"gemini-live-2.5-flash-preview-native-audio": {
id: "gemini-live-2.5-flash-preview-native-audio",
name: "Gemini Live 2.5 Flash Preview Native Audio",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text"],
cost: {
input: 0.5,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemma-3-27b-it": {
id: "gemma-3-27b-it",
name: "Gemma 3 27B",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 8192,
} satisfies Model<"google-generative-ai">,
"gemma-4-26b-a4b-it": {
id: "gemma-4-26b-a4b-it",
name: "Gemma 4 26B",
name: "Gemma 4 26B A4B IT",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
@@ -4650,12 +4501,12 @@ export const MODELS = {
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 8192,
contextWindow: 262144,
maxTokens: 32768,
} satisfies Model<"google-generative-ai">,
"gemma-4-31b-it": {
id: "gemma-4-31b-it",
name: "Gemma 4 31B",
name: "Gemma 4 31B IT",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
@@ -4668,8 +4519,8 @@ export const MODELS = {
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 8192,
contextWindow: 262144,
maxTokens: 32768,
} satisfies Model<"google-generative-ai">,
},
"google-vertex": {
@@ -7511,6 +7362,24 @@ export const MODELS = {
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"gemini-3.5-flash": {
id: "gemini-3.5-flash",
name: "Gemini 3.5 Flash",
api: "google-generative-ai",
provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1",
reasoning: true,
thinkingLevelMap: {"off":null},
input: ["text", "image"],
cost: {
input: 1.5,
output: 9,
cacheRead: 0.15,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-generative-ai">,
"glm-5": {
id: "glm-5",
name: "GLM-5",
@@ -7833,6 +7702,23 @@ export const MODELS = {
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"grok-build-0.1": {
id: "grok-build-0.1",
name: "Grok Build 0.1",
api: "openai-completions",
provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 1,
output: 2,
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"openai-completions">,
"kimi-k2.5": {
id: "kimi-k2.5",
name: "Kimi K2.5",
@@ -7884,23 +7770,6 @@ export const MODELS = {
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"minimax-m2.5-free": {
id: "minimax-m2.5-free",
name: "MiniMax M2.5 Free",
api: "anthropic-messages",
provider: "opencode",
baseUrl: "https://opencode.ai/zen",
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"minimax-m2.7": {
id: "minimax-m2.7",
name: "MiniMax M2.7",
@@ -8544,23 +8413,6 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"arcee-ai/trinity-large-preview": {
id: "arcee-ai/trinity-large-preview",
name: "Arcee AI: Trinity Large Preview",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.15,
output: 0.44999999999999996,
cacheRead: 0,
cacheWrite: 0,
},
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",
@@ -9023,7 +8875,7 @@ export const MODELS = {
cacheRead: 0.024999999999999998,
cacheWrite: 0.08333333333333334,
},
contextWindow: 1048576,
contextWindow: 1000000,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"google/gemini-2.0-flash-lite-001": {
@@ -9393,9 +9245,9 @@ export const MODELS = {
reasoning: false,
input: ["text"],
cost: {
input: 0.3,
output: 2.5,
cacheRead: 0.06,
input: 0.075,
output: 0.625,
cacheRead: 0.015,
cacheWrite: 0,
},
contextWindow: 262144,
@@ -11940,7 +11792,7 @@ export const MODELS = {
cost: {
input: 0.39,
output: 2.34,
cacheRead: 0.195,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
@@ -12023,13 +11875,13 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.32,
input: 0.317,
output: 3.1999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 81920,
maxTokens: 262140,
} satisfies Model<"openai-completions">,
"qwen/qwen3.6-35b-a3b": {
id: "qwen/qwen3.6-35b-a3b",
@@ -12099,6 +11951,23 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"qwen/qwen3.7-max": {
id: "qwen/qwen3.7-max",
name: "Qwen: Qwen3.7 Max",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: {
input: 2.5,
output: 7.5,
cacheRead: 0,
cacheWrite: 3.125,
},
contextWindow: 1000000,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"rekaai/reka-edge": {
id: "rekaai/reka-edge",
name: "Reka Edge",
@@ -12176,13 +12045,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.09999999999999999,
input: 0.09,
output: 0.3,
cacheRead: 0,
cacheRead: 0.02,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 65536,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"tencent/hy3-preview": {
id: "tencent/hy3-preview",
@@ -12286,6 +12155,23 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"x-ai/grok-build-0.1": {
id: "x-ai/grok-build-0.1",
name: "xAI: Grok Build 0.1",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 1,
output: 2,
cacheRead: 0.19999999999999998,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"xiaomi/mimo-v2-flash": {
id: "xiaomi/mimo-v2-flash",
name: "Xiaomi: MiMo-V2-Flash",
@@ -12567,13 +12453,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
input: 0.98,
output: 3.08,
cacheRead: 0.182,
cacheWrite: 0,
},
contextWindow: 202800,
maxTokens: 202800,
contextWindow: 202752,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"z-ai/glm-5v-turbo": {
id: "z-ai/glm-5v-turbo",
@@ -13358,6 +13244,23 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"alibaba/qwen3.7-max": {
id: "alibaba/qwen3.7-max",
name: "Qwen 3.7 Max",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 2.5,
output: 7.5,
cacheRead: 0.5,
cacheWrite: 3.125,
},
contextWindow: 991000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"anthropic/claude-3-haiku": {
id: "anthropic/claude-3-haiku",
name: "Claude 3 Haiku",
@@ -14397,6 +14300,23 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"mistral/mistral-medium-3.5": {
id: "mistral/mistral-medium-3.5",
name: "Mistral Medium Latest",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: {
input: 1.5,
output: 7.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"anthropic-messages">,
"mistral/mistral-small": {
id: "mistral/mistral-small",
name: "Mistral Small",
@@ -15412,6 +15332,23 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 1000000,
} satisfies Model<"anthropic-messages">,
"xai/grok-build-0.1": {
id: "xai/grok-build-0.1",
name: "Grok Build 0.1",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 1,
output: 2,
cacheRead: 0.19999999999999998,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"anthropic-messages">,
"xiaomi/mimo-v2-flash": {
id: "xiaomi/mimo-v2-flash",
name: "MiMo V2 Flash",
@@ -15703,108 +15640,6 @@ export const MODELS = {
} satisfies Model<"anthropic-messages">,
},
"xai": {
"grok-2": {
id: "grok-2",
name: "Grok 2",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2,
output: 10,
cacheRead: 2,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"grok-2-1212": {
id: "grok-2-1212",
name: "Grok 2 (1212)",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2,
output: 10,
cacheRead: 2,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"grok-2-latest": {
id: "grok-2-latest",
name: "Grok 2 Latest",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2,
output: 10,
cacheRead: 2,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"grok-2-vision": {
id: "grok-2-vision",
name: "Grok 2 Vision",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 2,
output: 10,
cacheRead: 2,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"grok-2-vision-1212": {
id: "grok-2-vision-1212",
name: "Grok 2 Vision (1212)",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 2,
output: 10,
cacheRead: 2,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"grok-2-vision-latest": {
id: "grok-2-vision-latest",
name: "Grok 2 Vision Latest",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 2,
output: 10,
cacheRead: 2,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"grok-3": {
id: "grok-3",
name: "Grok 3",
@@ -15848,8 +15683,8 @@ export const MODELS = {
reasoning: false,
input: ["text", "image"],
cost: {
input: 2,
output: 6,
input: 1.25,
output: 2.5,
cacheRead: 0.2,
cacheWrite: 0,
},
@@ -15865,8 +15700,8 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 2,
output: 6,
input: 1.25,
output: 2.5,
cacheRead: 0.2,
cacheWrite: 0,
},
@@ -15890,22 +15725,22 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 30000,
} satisfies Model<"openai-completions">,
"grok-beta": {
id: "grok-beta",
name: "Grok Beta",
"grok-build-0.1": {
id: "grok-build-0.1",
name: "Grok Build 0.1",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text"],
reasoning: true,
input: ["text", "image"],
cost: {
input: 5,
output: 15,
cacheRead: 5,
input: 1,
output: 2,
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"openai-completions">,
"grok-code-fast-1": {
id: "grok-code-fast-1",
@@ -15924,23 +15759,6 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"grok-vision-beta": {
id: "grok-vision-beta",
name: "Grok Vision Beta",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 15,
cacheRead: 5,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
},
"xiaomi": {
"mimo-v2-flash": {
+2 -1
View File
@@ -183,12 +183,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
try {
const client = new BedrockRuntimeClient(config);
const cacheRetention = resolveCacheRetention(options.cacheRetention);
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
let commandInput = {
modelId: model.id,
messages: convertMessages(context, model, cacheRetention),
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
inferenceConfig: {
...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }),
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
...(options.temperature !== undefined && { temperature: options.temperature }),
},
toolConfig: convertToolConfig(context.tools, options.toolChoice),
@@ -0,0 +1,80 @@
const CANCEL_MESSAGE = "Login cancelled";
const TIMEOUT_MESSAGE = "Device flow timed out";
const SLOW_DOWN_TIMEOUT_MESSAGE =
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
const MINIMUM_INTERVAL_MS = 1000;
// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds.
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds.
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
export type OAuthDeviceCodePollResult =
| { status: "pending" }
| { status: "slow_down" }
| { status: "complete"; accessToken: string }
| { status: "failed"; message: string };
export type OAuthDeviceCodePollOptions = {
intervalSeconds?: number;
expiresInSeconds?: number;
poll: () => Promise<OAuthDeviceCodePollResult>;
signal?: AbortSignal;
};
function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessage: string): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error(cancelMessage));
return;
}
const onAbort = () => {
clearTimeout(timeout);
reject(new Error(cancelMessage));
};
const timeout = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise<string> {
const deadline =
typeof options.expiresInSeconds === "number"
? Date.now() + options.expiresInSeconds * 1000
: Number.POSITIVE_INFINITY;
let intervalMs = Math.max(
MINIMUM_INTERVAL_MS,
Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000),
);
let slowDownResponses = 0;
while (Date.now() < deadline) {
if (options.signal?.aborted) {
throw new Error(CANCEL_MESSAGE);
}
const remainingMs = deadline - Date.now();
await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);
const result = await options.poll();
if (result.status === "complete") {
return result.accessToken;
}
if (result.status === "pending") {
continue;
}
if (result.status === "slow_down") {
slowDownResponses += 1;
// RFC 8628 section 3.5: apply this increase to this and all subsequent requests.
intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
continue;
}
throw new Error(result.message);
}
throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE);
}
+50 -101
View File
@@ -4,7 +4,8 @@
import { getModels } from "../../models.ts";
import type { Api, Model } from "../../types.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
type CopilotCredentials = OAuthCredentials & {
enterpriseUrl?: string;
@@ -20,14 +21,11 @@ const COPILOT_HEADERS = {
"Copilot-Integration-Id": "vscode-chat",
} as const;
const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2;
const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4;
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
interval: number;
interval?: number;
expires_in: number;
};
@@ -40,7 +38,6 @@ type DeviceTokenSuccessResponse = {
type DeviceTokenErrorResponse = {
error: string;
error_description?: string;
interval?: number;
};
export function normalizeDomain(input: string): string | null {
@@ -129,7 +126,7 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof interval !== "number" ||
(interval !== undefined && typeof interval !== "number") ||
typeof expiresIn !== "number"
) {
throw new Error("Invalid device code response fields");
@@ -144,95 +141,48 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
};
}
/**
* Sleep that can be interrupted by an AbortSignal
*/
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Login cancelled"));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new Error("Login cancelled"));
},
{ once: true },
);
});
}
async function pollForGitHubAccessToken(
domain: string,
deviceCode: string,
intervalSeconds: number,
expiresIn: number,
signal?: AbortSignal,
) {
async function pollForGitHubAccessToken(domain: string, device: DeviceCodeResponse, signal?: AbortSignal) {
const urls = getUrls(domain);
const deadline = Date.now() + expiresIn * 1000;
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
let slowDownResponses = 0;
return pollOAuthDeviceCodeFlow({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
signal,
poll: async () => {
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: device.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
while (Date.now() < deadline) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
const remainingMs = deadline - Date.now();
const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs);
await abortableSleep(waitMs, signal);
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
return (raw as DeviceTokenSuccessResponse).access_token;
}
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse;
if (error === "authorization_pending") {
continue;
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
return { status: "complete", accessToken: (raw as DeviceTokenSuccessResponse).access_token };
}
if (error === "slow_down") {
slowDownResponses += 1;
intervalMs =
typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000);
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
continue;
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const { error, error_description: description } = raw as DeviceTokenErrorResponse;
if (error === "authorization_pending") {
return { status: "pending" };
}
if (error === "slow_down") {
return { status: "slow_down" };
}
const descriptionSuffix = description ? `: ${description}` : "";
return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` };
}
const descriptionSuffix = description ? `: ${description}` : "";
throw new Error(`Device flow failed: ${error}${descriptionSuffix}`);
}
}
if (slowDownResponses > 0) {
throw new Error(
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.",
);
}
throw new Error("Device flow timed out");
return { status: "failed", message: "Invalid device token response" };
},
});
}
/**
@@ -319,13 +269,13 @@ async function enableAllGitHubCopilotModels(
/**
* Login with GitHub Copilot OAuth (device code flow)
*
* @param options.onAuth - Callback with URL and optional instructions (user code)
* @param options.onDeviceCode - Callback with URL and user code
* @param options.onPrompt - Callback to prompt user for input
* @param options.onProgress - Optional progress callback
* @param options.signal - Optional AbortSignal for cancellation
*/
export async function loginGitHubCopilot(options: {
onAuth: (url: string, instructions?: string) => void;
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
@@ -348,15 +298,14 @@ export async function loginGitHubCopilot(options: {
const domain = enterpriseDomain || "github.com";
const device = await startDeviceFlow(domain);
options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);
options.onDeviceCode({
userCode: device.user_code,
verificationUri: device.verification_uri,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
const githubAccessToken = await pollForGitHubAccessToken(
domain,
device.device_code,
device.interval,
device.expires_in,
options.signal,
);
const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal);
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
// Enable all models after successful login
@@ -371,7 +320,7 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginGitHubCopilot({
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
onDeviceCode: callbacks.onDeviceCode,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
signal: callbacks.signal,
+1
View File
@@ -9,6 +9,7 @@
// Anthropic
export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts";
export * from "./device-code.ts";
// GitHub Copilot
export {
getGitHubCopilotBaseUrl,
+9 -1
View File
@@ -23,6 +23,13 @@ export type OAuthAuthInfo = {
instructions?: string;
};
export type OAuthDeviceCodeInfo = {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
};
export type OAuthSelectOption = {
id: string;
label: string;
@@ -35,11 +42,12 @@ export type OAuthSelectPrompt = {
export interface OAuthLoginCallbacks {
onAuth: (info: OAuthAuthInfo) => void;
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
/** Show an interactive selector and return the selected option id, or undefined on cancel. */
onSelect?: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
onSelect: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
signal?: AbortSignal;
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.ts";
import { type BedrockOptions, streamBedrock } from "../src/providers/amazon-bedrock.ts";
import type { Context, Model } from "../src/types.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
interface BedrockThinkingPayload {
additionalModelRequestFields?: {
@@ -112,6 +113,39 @@ describe("Bedrock thinking payload", () => {
});
});
describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () => {
it(
"uses the model maxTokens cap instead of Bedrock's 4096-token default for adaptive Claude models",
{ retry: 2, timeout: 180000 },
async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-6");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
maxTokens: 6000,
};
const response = await streamBedrock(
model,
{
systemPrompt: "You are a deterministic text generator. Follow the requested output format exactly.",
messages: [
{
role: "user",
content:
"Output exactly 5200 repetitions of the token alpha, separated by single spaces. Do not number them. Do not use markdown. Do not add any other text.",
timestamp: Date.now(),
},
],
},
{ reasoning: "low" },
).result();
expect(response.stopReason, response.errorMessage).not.toBe("error");
expect(response.usage.output).toBeGreaterThan(4096);
},
);
});
describe("Application inference profile support", () => {
it("uses adaptive thinking when model.name contains the model name but ARN does not", async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
+75 -20
View File
@@ -29,7 +29,62 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.useRealTimers();
});
it("waits before the first poll and increases the safety margin after slow_down", async () => {
it("reports device-code details through onDeviceCode", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
interval: 1,
expires_in: 900,
});
}
if (url.endsWith("/login/oauth/access_token")) {
return jsonResponse({ access_token: "ghu_refresh_token" });
}
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires_at: 9999999999,
});
}
if (url.includes("/models/") && url.endsWith("/policy")) {
return new Response("", { status: 200 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilot({
onDeviceCode,
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(0);
expect(onDeviceCode).toHaveBeenCalledWith({
userCode: "ABCD-EFGH",
verificationUri: "https://github.com/login/device",
intervalSeconds: 1,
expiresInSeconds: 900,
});
await vi.advanceTimersByTimeAsync(1000);
await loginPromise;
});
it("waits before the first poll and increases the interval after slow_down", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-03-09T00:00:00Z");
vi.setSystemTime(startTime);
@@ -37,7 +92,7 @@ describe("GitHub Copilot OAuth device flow", () => {
const accessTokenPollTimes: number[] = [];
const accessTokenResponses = [
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }),
jsonResponse({ error: "slow_down", error_description: "slow down" }),
jsonResponse({ access_token: "ghu_refresh_token" }),
];
@@ -95,7 +150,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({
onAuth: () => {},
onDeviceCode: () => {},
onPrompt: async () => "",
onProgress: () => {},
});
@@ -103,28 +158,28 @@ describe("GitHub Copilot OAuth device flow", () => {
await vi.advanceTimersByTimeAsync(0);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(5999);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(5999);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toHaveLength(2);
await vi.advanceTimersByTimeAsync(13999);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
await loginPromise;
expect(accessTokenPollTimes).toEqual([
startTime.getTime() + 6000,
startTime.getTime() + 12000,
startTime.getTime() + 26000,
startTime.getTime() + 5000,
startTime.getTime() + 10000,
startTime.getTime() + 20000,
]);
});
@@ -135,8 +190,8 @@ describe("GitHub Copilot OAuth device flow", () => {
const accessTokenPollTimes: number[] = [];
const accessTokenResponses = [
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }),
jsonResponse({ error: "slow_down", error_description: "still too fast", interval: 15 }),
jsonResponse({ error: "slow_down", error_description: "slow down" }),
jsonResponse({ error: "slow_down", error_description: "still too fast" }),
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
];
@@ -168,28 +223,28 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({
onAuth: () => {},
onDeviceCode: () => {},
onPrompt: async () => "",
});
const rejection = expect(loginPromise).rejects.toThrow(
/Device flow timed out after one or more slow_down responses/,
);
await vi.advanceTimersByTimeAsync(6000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000]);
await vi.advanceTimersByTimeAsync(5000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(14000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]);
await vi.advanceTimersByTimeAsync(10000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(accessTokenPollTimes).toEqual([
startTime.getTime() + 6000,
startTime.getTime() + 20000,
startTime.getTime() + 5000,
startTime.getTime() + 15000,
startTime.getTime() + 25000,
]);
});
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts";
describe("OAuth device-code polling", () => {
afterEach(() => {
vi.useRealTimers();
});
it("waits before the first poll and returns the completed value", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const pollTimes: number[] = [];
const poll = vi.fn(async () => {
pollTimes.push(Date.now());
return pollTimes.length === 1
? { status: "pending" as const }
: { status: "complete" as const, accessToken: "token" };
});
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 30,
poll,
});
await vi.advanceTimersByTimeAsync(1999);
expect(pollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]);
await vi.advanceTimersByTimeAsync(2000);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([
new Date("2026-03-09T00:00:02Z").getTime(),
new Date("2026-03-09T00:00:04Z").getTime(),
]);
});
it("cancels an in-flight wait", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 5,
expiresInSeconds: 30,
poll: async () => ({ status: "pending" }),
signal: controller.signal,
});
controller.abort();
await expect(resultPromise).rejects.toThrow("Login cancelled");
});
});
+20
View File
@@ -2,6 +2,26 @@
## [Unreleased]
### Added
- Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)).
### Changed
- Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)).
### Fixed
- Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)).
- Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)).
- Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use.
- Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)).
- Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)).
- Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)).
- Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)).
## [0.75.4] - 2026-05-20
### New Features
- **Hardened npm install and release path** - Pi now ships the CLI with a generated shrinkwrap for transitive dependencies, blocks accidental lockfile changes, verifies dependency pinning and lifecycle-script allowlists in checks, disables lifecycle scripts for self-update and local release installs where supported, and smoke-tests isolated npm and Bun installs before release. See [Supply-chain hardening](../../README.md#supply-chain-hardening).
+1 -1
View File
@@ -408,7 +408,7 @@ pi update npm:@foo/pi-tools # update one package
pi config # enable/disable extensions, skills, prompts, themes
```
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Create a package by adding a `pi` key to `package.json`:
+32 -10
View File
@@ -263,17 +263,28 @@ pi.registerProvider("corporate-ai", {
name: "Corporate AI (SSO)",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
// Option 1: Browser-based OAuth
callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." });
// Option 2: Device code flow
callbacks.onDeviceCode({
userCode: "ABCD-1234",
verificationUri: "https://sso.corp.com/device"
const method = await callbacks.onSelect({
message: "Select login method:",
options: [
{ id: "browser", label: "Browser OAuth" },
{ id: "device", label: "Device code" }
]
});
if (!method) throw new Error("Login cancelled");
// Option 3: Prompt for token/code
const code = await callbacks.onPrompt({ message: "Enter SSO code:" });
let code: string;
if (method === "device") {
callbacks.onDeviceCode({
userCode: "ABCD-1234",
verificationUri: "https://sso.corp.com/device",
intervalSeconds: 5,
expiresInSeconds: 900
});
code = await pollDeviceCodeUntilComplete();
} else {
callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." });
code = await callbacks.onPrompt({ message: "Enter SSO code:" });
}
// Exchange for tokens (your implementation)
const tokens = await exchangeCodeForTokens(code);
@@ -322,10 +333,21 @@ interface OAuthLoginCallbacks {
onAuth(params: { url: string }): void;
// Show device code (for device authorization flow)
onDeviceCode(params: { userCode: string; verificationUri: string }): void;
onDeviceCode(params: {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}): void;
// Prompt user for input (for manual token entry)
onPrompt(params: { message: string }): Promise<string>;
// Show an interactive selector, e.g. to choose browser OAuth vs device code
onSelect(params: {
message: string;
options: { id: string; label: string }[];
}): Promise<string | undefined>;
}
```
+2 -2
View File
@@ -85,9 +85,9 @@ ssh://git@github.com/user/repo@v1
- HTTPS and SSH URLs are both supported.
- SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`).
- For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast.
- Refs pin the package and skip package updates (`pi update`, `pi update --extensions`).
- Refs are pinned tags or commits and skip package updates (`pi update`, `pi update --extensions`). Use `pi install git:host/user/repo@new-ref` to move an existing package to a new pinned ref.
- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- Runs `npm install` after clone or pull if `package.json` exists.
- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists.
**SSH examples:**
```bash
+2
View File
@@ -473,6 +473,8 @@ Specify which built-in tools to enable:
- `noTools: "all"` disables all tools
- `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled
The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers.
```typescript
import { createAgentSession } from "@earendil-works/pi-coding-agent";
@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.75.3",
"version": "0.75.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.75.3",
"version": "0.75.4",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.75.3",
"version": "0.75.4",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.75.3",
"version": "0.75.4",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.5.3",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.5.3",
"version": "1.5.4",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.5.3",
"version": "1.5.4",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.75.3",
"version": "0.75.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.75.3",
"version": "0.75.4",
"dependencies": {
"ms": "^2.1.3"
},
@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.75.3",
"version": "0.75.4",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
+13 -26
View File
@@ -1,17 +1,17 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.75.3",
"version": "0.75.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.75.3",
"version": "0.75.4",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.75.3",
"@earendil-works/pi-ai": "^0.75.3",
"@earendil-works/pi-tui": "^0.75.3",
"@earendil-works/pi-agent-core": "^0.75.4",
"@earendil-works/pi-ai": "^0.75.4",
"@earendil-works/pi-tui": "^0.75.4",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -473,11 +473,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.75.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.3.tgz",
"version": "0.75.4",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.4.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.75.3",
"@earendil-works/pi-ai": "^0.75.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -487,12 +487,13 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.75.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.3.tgz",
"version": "0.75.4",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@smithy/node-http-handler": "4.7.3",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.1",
"http-proxy-agent": "7.0.2",
@@ -509,16 +510,13 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.75.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz",
"version": "0.75.4",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
"marked": "15.0.12"
},
"optionalDependencies": {
"koffi": "2.16.2"
},
"engines": {
"node": ">=22.19.0"
}
@@ -1369,17 +1367,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/koffi": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz",
"integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==",
"license": "MIT",
"optional": true,
"hasInstallScript": true,
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.75.3",
"version": "0.75.4",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -39,9 +39,9 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.75.3",
"@earendil-works/pi-ai": "^0.75.3",
"@earendil-works/pi-tui": "^0.75.3",
"@earendil-works/pi-agent-core": "^0.75.4",
"@earendil-works/pi-ai": "^0.75.4",
"@earendil-works/pi-tui": "^0.75.4",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
+3 -6
View File
@@ -3,6 +3,7 @@ import { homedir } from "os";
import { basename, dirname, join, resolve, sep, win32 } from "path";
import { fileURLToPath } from "url";
import { spawnProcessSync } from "./utils/child-process.ts";
import { normalizePath } from "./utils/paths.ts";
// =============================================================================
// Package Detection
@@ -330,9 +331,7 @@ export function getPackageDir(): string {
// Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly)
const envDir = process.env.PI_PACKAGE_DIR;
if (envDir) {
if (envDir === "~") return homedir();
if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
return envDir;
return normalizePath(envDir);
}
if (isBunBinary) {
@@ -454,9 +453,7 @@ export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
export function expandTildePath(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return homedir() + path.slice(1);
return path;
return normalizePath(path);
}
const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/";
@@ -1,5 +1,6 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
@@ -337,7 +338,7 @@ export class AgentSessionRuntime {
* @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided.
*/
async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
const resolvedPath = resolve(inputPath);
const resolvedPath = resolvePath(inputPath);
if (!existsSync(resolvedPath)) {
throw new SessionImportFileNotFoundError(resolvedPath);
}
@@ -2,6 +2,7 @@ import { join } from "node:path";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts";
@@ -129,8 +130,8 @@ function applyExtensionFlagValues(
export async function createAgentSessionServices(
options: CreateAgentSessionServicesOptions,
): Promise<AgentSessionServices> {
const cwd = options.cwd;
const agentDir = options.agentDir ?? getAgentDir();
const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ? resolvePath(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"));
@@ -14,7 +14,7 @@
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, resolve } from "node:path";
import { basename, dirname } from "node:path";
import type {
Agent,
AgentEvent,
@@ -35,6 +35,7 @@ import {
} from "@earendil-works/pi-ai";
import { theme } from "../modes/interactive/theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { sleep } from "../utils/sleep.ts";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
@@ -2993,7 +2994,10 @@ export class AgentSession {
* @returns The resolved output file path.
*/
exportToJsonl(outputPath?: string): string {
const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
@@ -18,6 +18,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "f
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = {
@@ -53,7 +54,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
private authPath: string;
constructor(authPath: string = join(getAgentDir(), "auth.json")) {
this.authPath = authPath;
this.authPath = normalizePath(authPath);
}
private ensureParentDir(): void {
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.ts";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.ts";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import type { SessionEntry } from "../session-manager.ts";
import { SessionManager } from "../session-manager.ts";
@@ -270,7 +271,7 @@ export async function exportSessionToHtml(
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) {
const sessionBasename = basename(sessionFile, ".jsonl");
outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
@@ -286,12 +287,13 @@ export async function exportSessionToHtml(
*/
export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const resolvedInputPath = resolvePath(inputPath);
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
if (!existsSync(resolvedInputPath)) {
throw new Error(`File not found: ${resolvedInputPath}`);
}
const sm = SessionManager.open(inputPath);
const sm = SessionManager.open(resolvedInputPath);
const sessionData: SessionData = {
header: sm.getHeader(),
@@ -303,9 +305,9 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) {
const inputBasename = basename(inputPath, ".jsonl");
const inputBasename = basename(resolvedInputPath, ".jsonl");
outputPath = `${APP_NAME}-session-${inputBasename}.html`;
}
@@ -605,9 +605,12 @@
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
@@ -5,7 +5,6 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core";
@@ -24,6 +23,7 @@ import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts";
// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
// avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent.
import * as _bundledPiCodingAgent from "../../index.ts";
import { resolvePath } from "../../utils/paths.ts";
import { createEventBus, type EventBus } from "../event-bus.ts";
import type { ExecOptions } from "../exec.ts";
import { execCommand } from "../exec.ts";
@@ -115,31 +115,6 @@ function getAliases(): Record<string, string> {
return _aliases;
}
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(os.homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(os.homedir(), normalized.slice(1));
}
return normalized;
}
function resolvePath(extPath: string, cwd: string): string {
const expanded = expandPath(extPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
@@ -396,7 +371,7 @@ async function loadExtension(
eventBus: EventBus,
runtime: ExtensionRuntime,
): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd);
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath);
@@ -426,7 +401,8 @@ export async function loadExtensionFromFactory(
extensionPath = "<inline>",
): Promise<Extension> {
const extension = createExtension(extensionPath, extensionPath);
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
const resolvedCwd = resolvePath(cwd);
const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus);
await factory(api);
return extension;
}
@@ -437,11 +413,12 @@ export async function loadExtensionFromFactory(
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
if (error) {
errors.push({ path: extPath, error });
@@ -578,6 +555,8 @@ export async function discoverAndLoadExtensions(
agentDir: string = getAgentDir(),
eventBus?: EventBus,
): Promise<LoadExtensionsResult> {
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const allPaths: string[] = [];
const seen = new Set<string>();
@@ -592,16 +571,16 @@ export async function discoverAndLoadExtensions(
};
// 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/
const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions");
const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions");
addPaths(discoverExtensionsInDir(localExtDir));
// 2. Global extensions: agentDir/extensions/
const globalExtDir = path.join(agentDir, "extensions");
const globalExtDir = path.join(resolvedAgentDir, "extensions");
addPaths(discoverExtensionsInDir(globalExtDir));
// 3. Explicitly configured paths
for (const p of configuredPaths) {
const resolved = resolvePath(p, cwd);
const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true });
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
// Check for package.json with pi manifest or index.ts
const entries = resolveExtensionEntries(resolved);
@@ -617,5 +596,5 @@ export async function discoverAndLoadExtensions(
addPaths([resolved]);
}
return loadExtensions(allPaths, cwd, eventBus);
return loadExtensions(allPaths, resolvedCwd, eventBus);
}
@@ -25,6 +25,7 @@ import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
import {
@@ -339,7 +340,7 @@ export class ModelRegistry {
private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) {
this.authStorage = authStorage;
this.modelsJsonPath = modelsJsonPath;
this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined;
this.loadModels();
}
@@ -30,7 +30,7 @@ import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync } from "../utils/paths.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";
import { isStdoutTakenOver } from "./output-guard.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
@@ -763,8 +763,8 @@ export class DefaultPackageManager implements PackageManager {
private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.cwd = resolvePath(options.cwd);
this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager;
}
@@ -778,9 +778,21 @@ export class DefaultPackageManager implements PackageManager {
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope));
if (exists) {
return false;
const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope));
if (matchIndex !== -1) {
const existing = currentPackages[matchIndex];
if (this.getPackageSourceString(existing) === normalizedSource) {
return false;
}
const nextPackages = [...currentPackages];
nextPackages[matchIndex] =
typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource };
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
const nextPackages = [...currentPackages, normalizedSource];
if (scope === "project") {
@@ -1723,6 +1735,12 @@ export class DefaultPackageManager implements PackageManager {
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) {
if (source.ref) {
await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");
return;
}
const target = await this.getLocalGitUpdateTarget(targetDir);
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
return;
}
const gitRoot = this.getGitInstallRoot(scope);
@@ -1749,23 +1767,26 @@ export class DefaultPackageManager implements PackageManager {
}
const target = await this.getLocalGitUpdateTarget(targetDir);
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
}
private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise<void> {
// Fetch only the ref we will reset to, avoiding unrelated branch/tag noise.
await this.runCommand("git", target.fetchArgs, { cwd: targetDir });
await this.runCommand("git", fetchArgs, { cwd: targetDir });
const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const refreshedTargetHead = await this.runCommandCapture("git", ["rev-parse", target.ref], {
const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
if (localHead.trim() === refreshedTargetHead.trim()) {
if (localHead.trim() === targetHead.trim()) {
return;
}
await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir });
await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir });
// Clean untracked files (extensions should be pristine)
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
@@ -1947,19 +1968,11 @@ export class DefaultPackageManager implements PackageManager {
}
private resolvePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(this.cwd, trimmed);
return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true });
}
private resolvePathFromBase(input: string, baseDir: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(baseDir, trimmed);
return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true });
}
private collectPackageResources(
@@ -1,8 +1,8 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
import { basename, dirname, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
/**
@@ -185,19 +185,6 @@ export interface LoadPromptTemplatesOptions {
includeDefaults: boolean;
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolvePromptPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/**
* Load all prompt templates from:
* 1. Global: agentDir/prompts/
@@ -205,14 +192,14 @@ function resolvePromptPath(p: string, cwd: string): string {
* 3. Explicit prompt paths
*/
export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
const promptPaths = options.promptPaths;
const includeDefaults = options.includeDefaults;
const templates: PromptTemplate[] = [];
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
const globalPromptsDir = join(resolvedAgentDir, "prompts");
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
const isUnderPath = (target: string, root: string): boolean => {
@@ -252,7 +239,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt
// 3. Load explicit prompt paths
for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) {
continue;
}
@@ -1,5 +1,4 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, sep } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME } from "../config.ts";
@@ -8,7 +7,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath } from "../utils/paths.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
@@ -77,8 +76,8 @@ export function loadProjectContextFiles(options: {
cwd: string;
agentDir: string;
}): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
@@ -205,8 +204,8 @@ export class DefaultResourceLoader implements ResourceLoader {
private lastThemePaths: string[];
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.cwd = resolvePath(options.cwd);
this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({
@@ -409,8 +408,11 @@ export class DefaultResourceLoader implements ResourceLoader {
}
for (const p of this.additionalExtensionPaths) {
if (isLocalPath(p) && !existsSync(p)) {
extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) {
extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` });
}
}
}
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
@@ -423,8 +425,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);
for (const p of this.additionalSkillPaths) {
if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved });
}
}
}
@@ -435,8 +440,15 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastPromptPaths = promptPaths;
this.updatePromptsFromPaths(promptPaths, metadataByPath);
for (const p of this.additionalPromptTemplatePaths) {
if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) {
this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) {
this.promptDiagnostics.push({
type: "error",
message: "Prompt template path does not exist",
path: resolved,
});
}
}
}
@@ -447,8 +459,9 @@ 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 resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved });
}
}
@@ -478,10 +491,15 @@ export class DefaultResourceLoader implements ResourceLoader {
private normalizeExtensionPaths(
entries: Array<{ path: string; metadata: PathMetadata }>,
): Array<{ path: string; metadata: PathMetadata }> {
return entries.map((entry) => ({
path: this.resolveResourcePath(entry.path),
metadata: entry.metadata,
}));
return entries.map((entry) => {
const metadata = entry.metadata.baseDir
? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) }
: entry.metadata;
return {
path: this.resolveResourcePath(entry.path),
metadata,
};
});
}
private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
@@ -674,16 +692,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
private resolveResourcePath(p: string): string {
const trimmed = p.trim();
let expanded = trimmed;
if (trimmed === "~") {
expanded = homedir();
} else if (trimmed.startsWith("~/")) {
expanded = join(homedir(), trimmed.slice(2));
} else if (trimmed.startsWith("~")) {
expanded = join(homedir(), trimmed.slice(1));
}
return resolve(this.cwd, expanded);
return resolvePath(p, this.cwd, { trim: true });
}
private loadThemes(
@@ -704,7 +713,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
for (const p of paths) {
const resolved = resolve(this.cwd, p);
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) {
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });
continue;
+3 -2
View File
@@ -2,6 +2,7 @@ import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { AuthStorage } from "./auth-storage.ts";
@@ -191,8 +192,8 @@ function getAttributionHeaders(
* ```
*/
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd();
const agentDir = options.agentDir ?? getDefaultAgentDir();
const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry
@@ -16,6 +16,7 @@ import {
import { readdir, readFile, stat } from "fs/promises";
import { join, resolve } from "path";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import {
type BashExecutionMessage,
type CustomMessage,
@@ -425,8 +426,10 @@ export function buildSessionContext(
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
*/
export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(agentDir, "sessions", safePath);
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(resolvedAgentDir, "sessions", safePath);
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
@@ -435,9 +438,10 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA
/** Exported for testing */
export function loadEntriesFromFile(filePath: string): FileEntry[] {
if (!existsSync(filePath)) return [];
const resolvedFilePath = normalizePath(filePath);
if (!existsSync(resolvedFilePath)) return [];
const content = readFileSync(filePath, "utf8");
const content = readFileSync(resolvedFilePath, "utf8");
const entries: FileEntry[] = [];
const lines = content.trim().split("\n");
@@ -478,10 +482,11 @@ function isValidSessionFile(filePath: string): boolean {
/** Exported for testing */
export function findMostRecentSession(sessionDir: string): string | null {
const resolvedSessionDir = normalizePath(sessionDir);
try {
const files = readdirSync(sessionDir)
const files = readdirSync(resolvedSessionDir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => join(sessionDir, f))
.map((f) => join(resolvedSessionDir, f))
.filter(isValidSessionFile)
.map((path) => ({ path, mtime: statSync(path).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
@@ -717,11 +722,11 @@ export class SessionManager {
private leafId: string | null = null;
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
this.cwd = cwd;
this.sessionDir = sessionDir;
this.cwd = resolvePath(cwd);
this.sessionDir = normalizePath(sessionDir);
this.persist = persist;
if (persist && sessionDir && !existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
if (persist && this.sessionDir && !existsSync(this.sessionDir)) {
mkdirSync(this.sessionDir, { recursive: true });
}
if (sessionFile) {
@@ -733,7 +738,7 @@ export class SessionManager {
/** Switch to a different session file (used for resume and branching) */
setSessionFile(sessionFile: string): void {
this.sessionFile = resolve(sessionFile);
this.sessionFile = resolvePath(sessionFile);
if (existsSync(this.sessionFile)) {
this.fileEntries = loadEntriesFromFile(this.sessionFile);
@@ -1304,7 +1309,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/
static create(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
return new SessionManager(cwd, dir, undefined, true);
}
@@ -1315,13 +1320,14 @@ export class SessionManager {
* @param cwdOverride Optional cwd override instead of the session header cwd.
*/
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
const resolvedPath = resolvePath(path);
// Extract cwd from session header if possible, otherwise use process.cwd()
const entries = loadEntriesFromFile(path);
const entries = loadEntriesFromFile(resolvedPath);
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
// If no sessionDir provided, derive from file's parent directory
const dir = sessionDir ?? resolve(path, "..");
return new SessionManager(cwd, dir, path, true);
const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
return new SessionManager(cwd, dir, resolvedPath, true);
}
/**
@@ -1330,7 +1336,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const mostRecent = findMostRecentSession(dir);
if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true);
@@ -1351,17 +1357,19 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
*/
static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {
const sourceEntries = loadEntriesFromFile(sourcePath);
const resolvedSourcePath = resolvePath(sourcePath);
const resolvedTargetCwd = resolvePath(targetCwd);
const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
if (sourceEntries.length === 0) {
throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`);
throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);
}
const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined;
if (!sourceHeader) {
throw new Error(`Cannot fork: source session has no header: ${sourcePath}`);
throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
}
const dir = sessionDir ?? getDefaultSessionDir(targetCwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
@@ -1378,8 +1386,8 @@ export class SessionManager {
version: CURRENT_SESSION_VERSION,
id: newSessionId,
timestamp,
cwd: targetCwd,
parentSession: sourcePath,
cwd: resolvedTargetCwd,
parentSession: resolvedSourcePath,
};
appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);
@@ -1390,7 +1398,7 @@ export class SessionManager {
}
}
return new SessionManager(targetCwd, dir, newSessionFile, true);
return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
}
/**
@@ -1400,7 +1408,7 @@ export class SessionManager {
* @param onProgress Optional callback for progress updates (loaded, total)
*/
static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const sessions = await listSessionsFromDir(dir, onProgress);
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions;
@@ -1,9 +1,9 @@
import type { Transport } from "@earendil-works/pi-ai";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionSettings {
@@ -161,8 +161,10 @@ export class FileSettingsStorage implements SettingsStorage {
private projectSettingsPath: string;
constructor(cwd: string, agentDir: string) {
this.globalSettingsPath = join(agentDir, "settings.json");
this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
this.globalSettingsPath = join(resolvedAgentDir, "settings.json");
this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json");
}
private acquireLockSyncWithRetry(path: string): () => void {
@@ -577,16 +579,7 @@ export class SettingsManager {
getSessionDir(): string | undefined {
const sessionDir = this.settings.sessionDir;
if (!sessionDir) {
return sessionDir;
}
if (sessionDir === "~") {
return homedir();
}
if (sessionDir.startsWith("~/")) {
return join(homedir(), sessionDir.slice(2));
}
return sessionDir;
return sessionDir ? normalizePath(sessionDir) : sessionDir;
}
getDefaultProvider(): string | undefined {
+8 -21
View File
@@ -1,10 +1,9 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import ignore from "ignore";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { canonicalizePath } from "../utils/paths.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
@@ -381,28 +380,16 @@ export interface LoadSkillsOptions {
includeDefaults: boolean;
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolveSkillPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/**
* Load skills from all configured locations.
* Returns skills and any validation diagnostics.
*/
export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const { cwd, agentDir, skillPaths, includeDefaults } = options;
const { agentDir, skillPaths, includeDefaults } = options;
// Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir();
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());
const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>();
@@ -442,11 +429,11 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
if (includeDefaults) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true));
}
const userSkillsDir = join(resolvedAgentDir, "skills");
const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills");
const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills");
const isUnderPath = (target: string, root: string): boolean => {
const normalizedRoot = resolve(root);
@@ -466,7 +453,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
};
for (const rawPath of skillPaths) {
const resolvedPath = resolveSkillPath(rawPath, cwd);
const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) {
allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath });
continue;
+9 -3
View File
@@ -209,7 +209,15 @@ function rebuildBashResultRenderComponent(
const state = component.state;
component.clear();
const output = getTextOutput(result as any, showImages).trim();
let output = getTextOutput(result as any, showImages).trim();
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) {
const footerStart = output.lastIndexOf("\n\n[");
if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) {
output = output.slice(0, footerStart).trimEnd();
}
}
if (output) {
const styledOutput = output
@@ -245,8 +253,6 @@ function rebuildBashResultRenderComponent(
}
}
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
@@ -259,8 +259,16 @@ export function applyEditsToNormalizedContent(
return { baseContent, newContent };
}
/** Generate a standard unified patch. */
export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string {
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
context: contextLines,
headerOptions: Diff.FILE_HEADERS_ONLY,
});
}
/**
* Generate a unified diff string with line numbers and context.
* Generate a display-oriented diff string with line numbers and context.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateDiffString(
+6 -2
View File
@@ -13,6 +13,7 @@ import {
type EditDiffError,
type EditDiffResult,
generateDiffString,
generateUnifiedPatch,
normalizeToLF,
restoreLineEndings,
stripBom,
@@ -57,8 +58,10 @@ type LegacyEditToolInput = EditToolInput & {
};
export interface EditToolDetails {
/** Unified diff of the changes made */
/** Display-oriented diff of the changes made */
diff: string;
/** Standard unified patch of the changes made */
patch: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
}
@@ -353,6 +356,7 @@ export function createEditToolDefinition(
throwIfAborted();
const diffResult = generateDiffString(baseContent, newContent);
const patch = generateUnifiedPatch(path, baseContent, newContent);
return {
content: [
{
@@ -360,7 +364,7 @@ export function createEditToolDefinition(
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
};
} finally {
signal?.removeEventListener("abort", onAbort);
@@ -45,8 +45,10 @@ export class OutputAccumulator {
private tailStartsAtLineBoundary = true;
private totalRawBytes = 0;
private totalDecodedBytes = 0;
private totalLines = 1;
private completedLines = 0;
private totalLines = 0;
private currentLineBytes = 0;
private hasOpenLine = false;
private finished = false;
private tempFilePath: string | undefined;
@@ -164,10 +166,14 @@ export class OutputAccumulator {
}
if (newlines === 0) {
this.currentLineBytes += bytes;
this.hasOpenLine = true;
} else {
this.totalLines += newlines;
this.currentLineBytes = byteLength(text.slice(lastNewline + 1));
this.completedLines += newlines;
const tail = text.slice(lastNewline + 1);
this.currentLineBytes = byteLength(tail);
this.hasOpenLine = tail.length > 0;
}
this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0);
}
private trimTail(): void {
@@ -1,13 +1,8 @@
import { accessSync, constants } from "node:fs";
import { access } from "node:fs/promises";
import * as os from "node:os";
import { isAbsolute, resolve as resolvePath } from "node:path";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function tryMacOSScreenshotPath(filePath: string): string {
return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`);
@@ -42,19 +37,8 @@ async function fileExistsAsync(filePath: string): Promise<boolean> {
}
}
function normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
export function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}
/**
@@ -62,11 +46,7 @@ export function expandPath(filePath: string): string {
* Handles ~ expansion and absolute paths.
*/
export function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (isAbsolute(expanded)) {
return expanded;
}
return resolvePath(cwd, expanded);
return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}
export function resolveReadPath(filePath: string, cwd: string): string {
@@ -44,6 +44,17 @@ export interface TruncationOptions {
maxBytes?: number;
}
function splitLinesForCounting(content: string): string[] {
if (content.length === 0) {
return [];
}
const lines = content.split("\n");
if (content.endsWith("\n")) {
lines.pop();
}
return lines;
}
/**
* Format bytes as human-readable size.
*/
@@ -69,7 +80,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed
@@ -159,7 +170,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed
+5 -6
View File
@@ -5,7 +5,6 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/
import { resolve } from "node:path";
import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
@@ -46,7 +45,7 @@ import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
import { isLocalPath } from "./utils/paths.ts";
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
/**
@@ -147,9 +146,9 @@ type ResolvedSession =
* If it looks like a path, use as-is. Otherwise try to match as session ID prefix.
*/
async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {
// If it looks like a file path, use as-is
// If it looks like a file path, resolve it before handing it to the session manager.
if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
return { type: "path", path: sessionArg };
return { type: "path", path: resolvePath(sessionArg, cwd) };
}
// Try to match as session ID in current project first
@@ -381,7 +380,7 @@ function buildSessionOptions(
}
function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {
return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value));
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
}
async function promptForMissingSessionCwd(
@@ -501,7 +500,7 @@ export async function main(args: string[], options?: MainOptions) {
// sessionDir lookup during session selection.
const envSessionDir = process.env[ENV_SESSION_DIR];
const sessionDir =
parsed.sessionDir ??
(parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ??
(envSessionDir ? expandTildePath(envSessionDir) : undefined) ??
startupSettingsManager.getSessionDir();
let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);
@@ -567,7 +567,7 @@ class ResourceList implements Component, Focusable {
private getResourcePattern(item: ResourceItem): string {
const scope = item.metadata.scope as "user" | "project";
const baseDir = this.getTopLevelBaseDir(scope);
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope);
return relative(baseDir, item.path);
}
@@ -1,4 +1,4 @@
import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { exec } from "child_process";
import { theme } from "../theme/theme.ts";
@@ -86,7 +86,7 @@ export class LoginDialogComponent extends Container implements Focusable {
/**
* Called by onAuth callback - show URL and optional instructions
*/
showAuth(url: string, instructions?: string): void {
showAuth(url: string, instructions?: string, options: { autoOpenBrowser?: boolean } = {}): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
@@ -101,13 +101,40 @@ export class LoginDialogComponent extends Container implements Focusable {
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
}
// Try to open browser
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
exec(`${openCmd} "${url}"`);
if (options.autoOpenBrowser ?? true) {
this.openUrl(url);
}
this.tui.requestRender();
}
/**
* Called by onDeviceCode callback - show URL and user code.
*/
showDeviceCode(info: OAuthDeviceCodeInfo): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
this.openUrl(info.verificationUri);
this.tui.requestRender();
}
private openUrl(url: string): void {
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
try {
exec(`${openCmd} "${url}"`, () => {});
} catch {
// Ignore browser launch failures. The URL remains visible for manual opening/copying.
}
}
/**
* Show input for manual code/URL entry (for callback server providers)
*/
@@ -4823,13 +4823,15 @@ export class InteractiveMode {
manualCodeReject = undefined;
}
});
} else if (providerId === "github-copilot") {
// GitHub Copilot polls after onAuth
dialog.showWaiting("Waiting for browser authentication...");
}
// For Anthropic: onPrompt is called immediately after
},
onDeviceCode: (info) => {
dialog.showDeviceCode(info);
dialog.showWaiting("Waiting for authentication...");
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
return dialog.showPrompt(prompt.message, prompt.placeholder);
},
@@ -440,20 +440,7 @@ function getBuiltinThemes(): Record<string, ThemeJson> {
}
export function getAvailableThemes(): string[] {
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
const customThemesDir = getCustomThemesDir();
if (fs.existsSync(customThemesDir)) {
const files = fs.readdirSync(customThemesDir);
for (const file of files) {
if (file.endsWith(".json")) {
themes.add(file.slice(0, -5));
}
}
}
for (const name of registeredThemes.keys()) {
themes.add(name);
}
return Array.from(themes).sort();
return getAvailableThemesWithPaths().map(({ name }) => name);
}
export interface ThemeInfo {
@@ -463,35 +450,58 @@ export interface ThemeInfo {
export function getAvailableThemesWithPaths(): ThemeInfo[] {
const themesDir = getThemesDir();
const customThemesDir = getCustomThemesDir();
const result: ThemeInfo[] = [];
const seen = new Set<string>();
const addTheme = (themeInfo: ThemeInfo) => {
if (seen.has(themeInfo.name)) {
return;
}
seen.add(themeInfo.name);
result.push(themeInfo);
};
// Built-in themes
for (const name of Object.keys(getBuiltinThemes())) {
result.push({ name, path: path.join(themesDir, `${name}.json`) });
addTheme({ name, path: path.join(themesDir, `${name}.json`) });
}
// Custom themes
if (fs.existsSync(customThemesDir)) {
for (const file of fs.readdirSync(customThemesDir)) {
if (file.endsWith(".json")) {
const name = file.slice(0, -5);
if (!result.some((t) => t.name === name)) {
result.push({ name, path: path.join(customThemesDir, file) });
}
}
}
for (const themeInfo of getCustomThemeInfos()) {
addTheme(themeInfo);
}
for (const [name, theme] of registeredThemes.entries()) {
if (!result.some((t) => t.name === name)) {
result.push({ name, path: theme.sourcePath });
}
addTheme({ name, path: theme.sourcePath });
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
function getCustomThemeInfos(): ThemeInfo[] {
const customThemesDir = getCustomThemesDir();
const result: ThemeInfo[] = [];
if (!fs.existsSync(customThemesDir)) {
return result;
}
for (const file of fs.readdirSync(customThemesDir)) {
if (!file.endsWith(".json")) {
continue;
}
const themePath = path.join(customThemesDir, file);
try {
const customTheme = loadThemeFromPath(themePath);
if (customTheme.name) {
result.push({ name: customTheme.name, path: themePath });
}
} catch {
// Invalid themes are ignored here; the resource loader reports them
// during normal startup/reload.
}
}
return result;
}
function parseThemeJson(label: string, json: unknown): ThemeJson {
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
+50 -7
View File
@@ -1,7 +1,24 @@
import { realpathSync } from "node:fs";
import { isAbsolute, relative, resolve as resolvePath, sep } from "node:path";
import { homedir } from "node:os";
import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnProcessSync } from "./child-process.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
export interface PathInputOptions {
/** Trim leading/trailing whitespace before normalization. */
trim?: boolean;
/** Expand leading `~` to a home directory. Defaults to true. */
expandTilde?: boolean;
/** Home directory used for `~` expansion. Defaults to `os.homedir()`. */
homeDir?: string;
/** Strip a leading `@`, used for CLI @file paths. */
stripAtPrefix?: boolean;
/** Normalize unicode space variants to regular spaces. */
normalizeUnicodeSpaces?: boolean;
}
/**
* Resolve a path to its canonical (real) form, following symlinks.
* Falls back to the raw path if resolution fails (e.g. the target does
@@ -18,12 +35,12 @@ export function canonicalizePath(path: string): string {
/**
* Returns true if the value is NOT a package source (npm:, git:, etc.)
* or a URL protocol. Bare names and relative paths without ./ prefix
* or a remote URL protocol. Bare names, relative paths, and file: URLs
* are considered local.
*/
export function isLocalPath(value: string): boolean {
const trimmed = value.trim();
// Known non-local prefixes
// Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath().
if (
trimmed.startsWith("npm:") ||
trimmed.startsWith("git:") ||
@@ -37,13 +54,39 @@ export function isLocalPath(value: string): boolean {
return true;
}
function resolveAgainstCwd(filePath: string, cwd: string): string {
return isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath);
export function normalizePath(input: string, options: PathInputOptions = {}): string {
let normalized = options.trim ? input.trim() : input;
if (options.normalizeUnicodeSpaces) {
normalized = normalized.replace(UNICODE_SPACES, " ");
}
if (options.stripAtPrefix && normalized.startsWith("@")) {
normalized = normalized.slice(1);
}
if (options.expandTilde ?? true) {
const home = options.homeDir ?? homedir();
if (normalized === "~") return home;
if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
return join(home, normalized.slice(2));
}
}
if (/^file:\/\//.test(normalized)) {
return fileURLToPath(normalized);
}
return normalized;
}
export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string {
const normalized = normalizePath(input, options);
const normalizedBaseDir = normalizePath(baseDir);
return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized);
}
export function getCwdRelativePath(filePath: string, cwd: string): string | undefined {
const resolvedCwd = resolvePath(cwd);
const resolvedPath = resolveAgainstCwd(filePath, resolvedCwd);
const resolvedPath = resolvePath(filePath, resolvedCwd);
const relativePath = relative(resolvedCwd, resolvedPath);
const isInsideCwd =
relativePath === "" ||
@@ -53,7 +96,7 @@ export function getCwdRelativePath(filePath: string, cwd: string): string | unde
}
export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string {
const absolutePath = resolveAgainstCwd(filePath, cwd);
const absolutePath = resolvePath(filePath, cwd);
return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/");
}
@@ -123,6 +123,31 @@ describe("extensions discovery", () => {
expect(result.extensions[0].path).toContain("main.ts");
});
it("keeps package.json pi extension entries with leading tilde package-relative", async () => {
const subdir = path.join(extensionsDir, "tilde-package");
const directExtensionPath = path.join(subdir, "~entry.ts");
const slashExtensionPath = path.join(subdir, "~", "entry.ts");
fs.mkdirSync(path.join(subdir, "~"), { recursive: true });
fs.writeFileSync(directExtensionPath, extensionCode);
fs.writeFileSync(slashExtensionPath, extensionCode);
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
name: "tilde-package",
pi: {
extensions: ["~entry.ts", "~/entry.ts"],
},
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions.map((extension) => extension.path).sort()).toEqual(
[directExtensionPath, slashExtensionPath].sort(),
);
});
it("package.json can declare multiple extensions", async () => {
const subdir = path.join(extensionsDir, "my-package");
fs.mkdirSync(subdir);
@@ -25,6 +25,16 @@ class MockSpawnedProcess extends EventEmitter {
}
}
interface PackageManagerInternals {
runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void>;
runCommandCapture(
command: string,
args: string[],
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
): Promise<string>;
getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>;
}
// Helper to check if a resource is enabled
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
const normalizedPath = normalizeForMatch(r.path);
@@ -574,6 +584,40 @@ Content`,
);
});
it("should keep pi manifest entries with leading tilde package-relative", async () => {
const pkgDir = join(tempDir, "tilde-manifest-package");
const directExtensionPath = join(pkgDir, "~extensions", "main.ts");
const slashExtensionPath = join(pkgDir, "~", "extensions", "alt.ts");
const directSkillPath = join(pkgDir, "~skills", "direct-skill", "SKILL.md");
const slashSkillPath = join(pkgDir, "~", "skills", "slash-skill", "SKILL.md");
mkdirSync(join(pkgDir, "~extensions"), { recursive: true });
mkdirSync(join(pkgDir, "~", "extensions"), { recursive: true });
mkdirSync(join(pkgDir, "~skills", "direct-skill"), { recursive: true });
mkdirSync(join(pkgDir, "~", "skills", "slash-skill"), { recursive: true });
writeFileSync(directExtensionPath, "export default function() {}");
writeFileSync(slashExtensionPath, "export default function() {}");
writeFileSync(directSkillPath, "---\nname: direct-skill\ndescription: Direct\n---\nContent");
writeFileSync(slashSkillPath, "---\nname: slash-skill\ndescription: Slash\n---\nContent");
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "tilde-manifest-package",
pi: {
extensions: ["~extensions/main.ts", "~/extensions/alt.ts"],
skills: ["~skills", "~/skills"],
},
}),
);
const result = await packageManager.resolveExtensionSources([pkgDir]);
expect(result.extensions.some((r) => r.path === directExtensionPath && r.enabled)).toBe(true);
expect(result.extensions.some((r) => r.path === slashExtensionPath && r.enabled)).toBe(true);
expect(result.skills.some((r) => r.path === directSkillPath && r.enabled)).toBe(true);
expect(result.skills.some((r) => r.path === slashSkillPath && r.enabled)).toBe(true);
});
it("should handle directories with auto-discovery layout", async () => {
const pkgDir = join(tempDir, "auto-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
@@ -693,6 +737,62 @@ Content`,
expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir });
});
it("should reconcile an existing git checkout to a pinned ref during install", async () => {
const source = "git:github.com/user/repo@v2";
const targetDir = join(agentDir, "git", "github.com", "user", "repo");
mkdirSync(targetDir, { recursive: true });
writeFileSync(join(targetDir, "package.json"), JSON.stringify({ name: "repo", version: "1.0.0" }));
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => {
if (args[0] === "rev-parse" && args[1] === "HEAD") {
return "old-head";
}
if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") {
return "new-head";
}
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
});
const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined);
await packageManager.install(source);
expect(runCommandSpy).toHaveBeenCalledWith("git", ["fetch", "origin", "v2"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir });
});
it("should reconcile an existing git checkout to its update target when installing without a ref", async () => {
const source = "git:github.com/user/repo";
const targetDir = join(agentDir, "git", "github.com", "user", "repo");
const fetchArgs = ["fetch", "--prune", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"];
mkdirSync(targetDir, { recursive: true });
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
vi.spyOn(managerWithInternals, "getLocalGitUpdateTarget").mockResolvedValue({
ref: "origin/HEAD",
head: "new-head",
fetchArgs,
});
vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => {
if (args[0] === "rev-parse" && args[1] === "HEAD") {
return "old-head";
}
if (args[0] === "rev-parse" && args[1] === "origin/HEAD") {
return "new-head";
}
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
});
const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined);
await packageManager.install(source);
expect(runCommandSpy).toHaveBeenCalledWith("git", fetchArgs, { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
});
it("should use plain install for git package dependencies when npmCommand is configured", async () => {
settingsManager = SettingsManager.inMemory({
npmCommand: ["pnpm"],
@@ -1061,6 +1161,47 @@ Content`,
expect(removed).toBe(true);
expect(settingsManager.getGlobalSettings().packages ?? []).toHaveLength(0);
});
it("should return false when adding the same git source with the same ref", () => {
const first = packageManager.addSourceToSettings("git:github.com/user/repo@v1");
expect(first).toBe(true);
const second = packageManager.addSourceToSettings("git:github.com/user/repo@v1");
expect(second).toBe(false);
expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v1"]);
});
it("should update the ref when adding the same git source with a different ref", () => {
packageManager.addSourceToSettings("git:github.com/user/repo@v1");
const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2");
expect(updated).toBe(true);
expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v2"]);
});
it("should preserve package filters when replacing a package source ref", () => {
settingsManager.setPackages([
{
source: "git:github.com/user/repo@v1",
extensions: ["extensions/main.ts"],
skills: [],
prompts: ["prompts/review.md"],
themes: ["themes/dark.json"],
},
]);
const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2");
expect(updated).toBe(true);
expect(settingsManager.getGlobalSettings().packages).toEqual([
{
source: "git:github.com/user/repo@v2",
extensions: ["extensions/main.ts"],
skills: [],
prompts: ["prompts/review.md"],
themes: ["themes/dark.json"],
},
]);
});
});
describe("HTTPS git URL parsing (old behavior)", () => {
+14 -2
View File
@@ -16,6 +16,11 @@ describe("path-utils", () => {
expect(result).not.toContain("~/");
});
it("should keep tilde-prefixed filenames literal", () => {
expect(expandPath("~draft.md")).toBe("~draft.md");
expect(expandPath("@~draft.md")).toBe("~draft.md");
});
it("should normalize Unicode spaces", () => {
// Non-breaking space (U+00A0) should become regular space
const withNBSP = "file\u00A0name.txt";
@@ -26,14 +31,21 @@ describe("path-utils", () => {
describe("resolveToCwd", () => {
it("should resolve absolute paths as-is", () => {
const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd");
expect(result).toBe("/absolute/path/file.txt");
const absolutePath = resolve(tmpdir(), "absolute", "path", "file.txt");
const result = resolveToCwd(absolutePath, resolve(tmpdir(), "some", "cwd"));
expect(result).toBe(absolutePath);
});
it("should resolve relative paths against cwd", () => {
const result = resolveToCwd("relative/file.txt", "/some/cwd");
expect(result).toBe(resolve("/some/cwd", "relative/file.txt"));
});
it("should resolve tilde-prefixed filenames against cwd", () => {
const cwd = join(tmpdir(), "pi-path-utils-cwd");
expect(resolveToCwd("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
expect(resolveToCwd("@~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
});
});
describe("resolveReadPath", () => {
+57 -3
View File
@@ -1,8 +1,9 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.ts";
import { canonicalizePath, getCwdRelativePath, isLocalPath, normalizePath, resolvePath } from "../src/utils/paths.ts";
let tempDir: string;
@@ -73,6 +74,55 @@ describe("getCwdRelativePath", () => {
});
});
describe("resolvePath", () => {
it("expands only home tilde shortcuts", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(normalizePath("~")).toBe(homedir());
expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt"));
expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
expect(normalizePath("~draft.md")).toBe("~draft.md");
});
it("resolves relative paths against the base directory", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt"));
expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt"));
});
it("accepts file URLs", () => {
const dir = createTempDir();
const filePath = join(dir, "file with spaces.txt");
expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath));
});
it("throws for invalid file URLs", () => {
expect(() => resolvePath("file:///%E0%A4%A")).toThrow();
});
it("preserves POSIX absolute paths with literal percent sequences", () => {
if (process.platform === "win32") {
return;
}
const dir = createTempDir();
for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) {
expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath));
}
});
it("does not treat Windows file URL pathname strings as native paths", () => {
if (process.platform !== "win32") {
return;
}
const dir = createTempDir();
const filePath = join(dir, "dir", "SKILL.md");
const pathname = pathToFileURL(filePath).pathname;
expect(pathname).toMatch(/^\/[A-Za-z]:/);
expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname));
});
});
describe("isLocalPath", () => {
it("returns true for bare names", () => {
expect(isLocalPath("my-package")).toBe(true);
@@ -82,6 +132,10 @@ describe("isLocalPath", () => {
expect(isLocalPath("./foo")).toBe(true);
});
it("returns true for file URLs", () => {
expect(isLocalPath("file:///tmp/foo")).toBe(true);
});
it("returns false for npm: protocol", () => {
expect(isLocalPath("npm:package")).toBe(false);
});
@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
@@ -405,6 +406,44 @@ Extra prompt content`,
expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra");
expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath);
});
it("should load extension resources returned as file URLs", async () => {
const extraSkillDir = join(tempDir, "extra skills", "file-url-skill");
mkdirSync(extraSkillDir, { recursive: true });
const skillPath = join(extraSkillDir, "SKILL.md");
writeFileSync(
skillPath,
`---
name: file-url-skill
description: File URL skill
---
Extra content`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
loader.extendResources({
skillPaths: [
{
path: pathToFileURL(extraSkillDir).href,
metadata: {
source: "extension:file-url",
scope: "temporary",
origin: "top-level",
baseDir: extraSkillDir,
},
},
],
});
const { skills, diagnostics } = loader.getSkills();
expect(diagnostics).toEqual([]);
const loadedSkill = skills.find((skill) => skill.name === "file-url-skill");
expect(loadedSkill).toBeDefined();
expect(loadedSkill?.filePath).toBe(skillPath);
expect(loadedSkill?.sourceInfo?.source).toBe("extension:file-url");
});
});
describe("noSkills option", () => {
@@ -0,0 +1,51 @@
import { mkdirSync, mkdtempSync, readFileSync, 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 {
getAvailableThemes,
getAvailableThemesWithPaths,
setRegisteredThemes,
} from "../src/modes/interactive/theme/theme.ts";
type ThemeFile = {
name: string;
vars?: Record<string, string | number>;
colors: Record<string, string | number>;
};
describe("theme picker", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-theme-picker-"));
const agentDir = join(tempRoot, "agent");
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
mkdirSync(join(agentDir, "themes"), { recursive: true });
setRegisteredThemes([]);
});
afterEach(() => {
setRegisteredThemes([]);
rmSync(tempRoot, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it("uses custom theme content names instead of file names", () => {
const darkTheme = JSON.parse(
readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf-8"),
) as ThemeFile;
const customTheme: ThemeFile = {
...darkTheme,
name: "bar",
};
const themePath = join(process.env.PI_CODING_AGENT_DIR!, "themes", "foo.json");
writeFileSync(themePath, JSON.stringify(customTheme, null, 2));
expect(getAvailableThemes()).toContain("bar");
expect(getAvailableThemes()).not.toContain("foo");
expect(getAvailableThemesWithPaths()).toContainEqual({ name: "bar", path: themePath });
expect(getAvailableThemesWithPaths().some((theme) => theme.name === "foo")).toBe(false);
});
});
@@ -123,6 +123,43 @@ describe("ToolExecutionComponent parity", () => {
await promise;
});
test("bash renderer does not duplicate final full output truncation details", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 1; i <= 4000; i++) {
onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`));
}
return { exitCode: 0 };
},
};
const tool = createBashToolDefinition(process.cwd(), { operations });
const result = await tool.execute(
"tool-bash-1b",
{ command: "generate output" },
undefined,
undefined,
{} as never,
);
const component = new ToolExecutionComponent(
"bash",
"tool-bash-1b",
{ command: "generate output" },
{},
tool,
createFakeTui(),
process.cwd(),
);
component.setExpanded(true);
component.updateResult({ ...result, isError: false }, false);
const rendered = stripAnsi(component.render(200).join("\n"));
expect(rendered.match(/Full output:/g)?.length ?? 0).toBe(1);
expect(rendered).toMatch(/line-4000[^\n]*\n[^\S\n]*\n \[Full output:/);
expect(rendered).not.toMatch(/line-4000[^\n]*\n[^\S\n]*\n[^\S\n]*\n \[Full output:/);
expect(rendered).toContain("Truncated: showing 2000 of 4000 lines");
expect(rendered).not.toContain("[Showing lines 2001-4000 of 4000. Full output:");
});
test("does not duplicate built-in headers when passed the active built-in definition", () => {
const component = new ToolExecutionComponent(
"read",
+29
View File
@@ -1,3 +1,4 @@
import { applyPatch } from "diff";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
@@ -239,6 +240,12 @@ describe("Coding Agent Tools", () => {
expect(result.details.diff).toBeDefined();
expect(typeof result.details.diff).toBe("string");
expect(result.details.diff).toContain("testing");
expect(result.details.patch).toContain("--- ");
expect(result.details.patch).toContain("+++ ");
expect(result.details.patch).toContain("@@");
expect(result.details.patch).toContain("-Hello, world!");
expect(result.details.patch).toContain("+Hello, testing!");
expect(applyPatch(originalContent, result.details.patch)).toBe("Hello, testing!");
});
it("should fail if text not found", async () => {
@@ -573,6 +580,28 @@ describe("Coding Agent Tools", () => {
expect(getTextOutput(result)).toContain("line 4999");
});
it("should not count a trailing newline as an extra truncated bash output line", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 1; i <= 4000; i++) {
onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`, "utf-8"));
}
return { exitCode: 0 };
},
};
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-trailing-newline-line-count", { command: "many-lines" });
const output = getTextOutput(result);
expect(result.details?.truncation?.totalLines).toBe(4000);
expect(result.details?.truncation?.outputLines).toBe(2000);
expect(output).toContain("line-2001");
expect(output).toContain("line-4000");
expect(output).toMatch(/\[Showing lines 2001-4000 of 4000\. Full output: /);
expect(output).not.toContain("4001");
});
it("should decode UTF-8 characters split across output chunks", async () => {
const euro = Buffer.from("€\n", "utf-8");
const operations: BashOperations = {
+6
View File
@@ -4,6 +4,12 @@
### Changed
- Replaced the optional `koffi` dependency for Windows VT input with a tiny vendored native helper, reducing install size while preserving Shift+Tab handling ([#4480](https://github.com/earendil-works/pi/issues/4480)).
## [0.75.4] - 2026-05-20
### Changed
- Removed the package-level development watch script now that the root TypeScript check validates strip-only-compatible sources.
### Fixed
@@ -0,0 +1,53 @@
#include <windows.h>
#ifndef ENABLE_VIRTUAL_TERMINAL_INPUT
#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200
#endif
#define NAPI_AUTO_LENGTH ((unsigned long long)-1)
typedef void* napi_env;
typedef void* napi_value;
typedef void* napi_callback_info;
typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info);
typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*);
typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*);
static void* node_symbol(const char* name) {
HMODULE module = GetModuleHandleA(0);
void* proc = module ? (void*)GetProcAddress(module, name) : 0;
if (proc) return proc;
module = GetModuleHandleA("node.dll");
return module ? (void*)GetProcAddress(module, name) : 0;
}
static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) {
(void)info;
HANDLE handle = GetStdHandle(STD_INPUT_HANDLE);
DWORD mode = 0;
int enabled = handle != INVALID_HANDLE_VALUE &&
GetConsoleMode(handle, &mode) &&
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT);
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
napi_value result = 0;
if (napi_get_boolean) napi_get_boolean(env, enabled, &result);
return result;
}
__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) {
napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function");
napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property");
napi_value fn = 0;
if (napi_create_function &&
napi_set_named_property &&
napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) {
napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn);
}
return exports;
}
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-tui",
"version": "0.75.3",
"version": "0.75.4",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",
@@ -12,6 +12,7 @@
},
"files": [
"dist/**/*",
"native/win32/prebuilds/**/*.node",
"README.md"
],
"keywords": [
@@ -38,9 +39,6 @@
"get-east-asian-width": "1.6.0",
"marked": "15.0.12"
},
"optionalDependencies": {
"koffi": "2.16.2"
},
"devDependencies": {
"@xterm/headless": "5.5.0",
"@xterm/xterm": "5.5.0",
+23 -15
View File
@@ -1,6 +1,7 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { setKittyProtocolActive } from "./keys.ts";
import { StdinBuffer } from "./stdin-buffer.ts";
@@ -210,23 +211,30 @@ export class ProcessTerminal implements Terminal {
private enableWindowsVTInput(): void {
if (process.platform !== "win32") return;
try {
// Dynamic require to avoid bundling koffi's 74MB of cross-platform
// native binaries into every compiled binary. Koffi is only needed
// on Windows for VT input support.
const koffi = cjsRequire("koffi");
const k32 = koffi.load("kernel32.dll");
const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)");
const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)");
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") return;
const STD_INPUT_HANDLE = -10;
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
const handle = GetStdHandle(STD_INPUT_HANDLE);
const mode = new Uint32Array(1);
GetConsoleMode(handle, mode);
SetConsoleMode(handle, mode[0]! | ENABLE_VIRTUAL_TERMINAL_INPUT);
// Dynamic require so non-Windows and bundled/browser paths never load the
// native helper. In the npm package native/ is next to dist/; in compiled
// binary archives native/ is copied next to the executable.
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
const candidates = [
path.join(moduleDir, "..", nativePath),
path.join(moduleDir, nativePath),
path.join(path.dirname(process.execPath), nativePath),
];
for (const modulePath of candidates) {
try {
const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean };
helper.enableVirtualTerminalInput?.();
return;
} catch {
// Try the next possible packaging location.
}
}
} catch {
// koffi not available — Shift+Tab won't be distinguishable from Tab
// Native helper not available — Shift+Tab won't be distinguishable from Tab.
}
}