Merge main into fix/settings-extensions-loading, resolve CHANGELOG conflict

This commit is contained in:
Mario Zechner 2026-01-05 17:58:13 +01:00
commit 4a7e8672af
9 changed files with 286 additions and 24 deletions

62
package-lock.json generated
View file

@ -9,7 +9,8 @@
"version": "0.0.3",
"workspaces": [
"packages/*",
"packages/web-ui/example"
"packages/web-ui/example",
"packages/coding-agent/examples/extensions/with-deps"
],
"dependencies": {
"@mariozechner/pi-coding-agent": "^0.30.2",
@ -2881,6 +2882,13 @@
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
@ -2890,6 +2898,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/proper-lockfile": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "*"
}
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
@ -5662,6 +5680,10 @@
"@napi-rs/canvas": "^0.1.81"
}
},
"node_modules/pi-extension-with-deps": {
"resolved": "packages/coding-agent/examples/extensions/with-deps",
"link": true
},
"node_modules/pi-web-ui-example": {
"resolved": "packages/web-ui/example",
"link": true
@ -5745,6 +5767,32 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/proper-lockfile/node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/proper-lockfile/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -7169,6 +7217,7 @@
"glob": "^11.0.3",
"jiti": "^2.6.1",
"marked": "^15.0.12",
"proper-lockfile": "^4.1.2",
"sharp": "^0.34.2"
},
"bin": {
@ -7177,6 +7226,7 @@
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/node": "^24.3.0",
"@types/proper-lockfile": "^4.1.4",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
},
@ -7184,6 +7234,16 @@
"node": ">=20.0.0"
}
},
"packages/coding-agent/examples/extensions/with-deps": {
"name": "pi-extension-with-deps",
"version": "1.0.0",
"dependencies": {
"ms": "^2.1.3"
},
"devDependencies": {
"@types/ms": "^2.1.0"
}
},
"packages/coding-agent/node_modules/@types/node": {
"version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",

View file

@ -4,7 +4,8 @@
"type": "module",
"workspaces": [
"packages/*",
"packages/web-ui/example"
"packages/web-ui/example",
"packages/coding-agent/examples/extensions/with-deps"
],
"scripts": {
"clean": "npm run clean --workspaces",

View file

@ -2,9 +2,17 @@
## [Unreleased]
### Added
- Extension example: add `claude-rules` to load `.claude/rules/` entries into the system prompt ([#461](https://github.com/badlogic/pi-mono/pull/461) by [@vaayne](https://github.com/vaayne))
### Fixed
- Extensions defined in `settings.json` were not loaded ([#463](https://github.com/badlogic/pi-mono/pull/463) by [@melihmucuk](https://github.com/melihmucuk))
- OAuth refresh no longer logs users out when multiple pi instances are running ([#466](https://github.com/badlogic/pi-mono/pull/466) by [@Cursivez](https://github.com/Cursivez))
- Migration warnings now ignore `fd.exe` and `rg.exe` in `tools/` on Windows ([#458](https://github.com/badlogic/pi-mono/pull/458) by [@carlosgtrz](https://github.com/carlosgtrz))
- CI: add `examples/extensions/with-deps` to workspaces to fix typecheck ([#467](https://github.com/badlogic/pi-mono/pull/467) by [@aliou](https://github.com/aliou))
- SDK: passing `extensions: []` now disables extension discovery as documented ([#465](https://github.com/badlogic/pi-mono/pull/465) by [@aliou](https://github.com/aliou))
## [0.36.0] - 2026-01-05

View file

@ -0,0 +1,83 @@
/**
* Claude Rules Extension
*
* Scans the project's .claude/rules/ folder for rule files and lists them
* in the system prompt. The agent can then use the read tool to load
* specific rules when needed.
*
* Best practices for .claude/rules/:
* - Keep rules focused: Each file should cover one topic (e.g., testing.md, api-design.md)
* - Use descriptive filenames: The filename should indicate what the rules cover
* - Use conditional rules sparingly: Only add paths frontmatter when rules truly apply to specific file types
* - Organize with subdirectories: Group related rules (e.g., frontend/, backend/)
*
* Usage:
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
* 2. Create .claude/rules/ folder in your project root
* 3. Add .md files with your rules
*/
import * as fs from "node:fs";
import * as path from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
/**
* Recursively find all .md files in a directory
*/
function findMarkdownFiles(dir: string, basePath: string = ""): string[] {
const results: string[] = [];
if (!fs.existsSync(dir)) {
return results;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(relativePath);
}
}
return results;
}
export default function claudeRulesExtension(pi: ExtensionAPI) {
let ruleFiles: string[] = [];
let rulesDir: string = "";
// Scan for rules on session start
pi.on("session_start", async (_event, ctx) => {
rulesDir = path.join(ctx.cwd, ".claude", "rules");
ruleFiles = findMarkdownFiles(rulesDir);
if (ruleFiles.length > 0) {
ctx.ui.notify(`Found ${ruleFiles.length} rule(s) in .claude/rules/`, "info");
}
});
// Append available rules to system prompt
pi.on("before_agent_start", async () => {
if (ruleFiles.length === 0) {
return;
}
const rulesList = ruleFiles.map((f) => `- .claude/rules/${f}`).join("\n");
return {
systemPromptAppend: `
## Project Rules
The following project rules are available in .claude/rules/:
${rulesList}
When working on tasks related to these rules, use the read tool to load the relevant rule files for guidance.
`,
};
});
}

View file

@ -1,5 +1,6 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "1.0.0",
"type": "module",
"pi": {

View file

@ -6,10 +6,6 @@
* IMPORTANT: When providing `tools` with a custom `cwd`, use the tool factory
* functions (createReadTool, createBashTool, etc.) to ensure tools resolve
* paths relative to your cwd.
*
* NOTE: Extensions (extensions, custom tools) are always loaded via discovery.
* To use custom extensions, place them in the extensions directory or
* pass paths via additionalExtensionPaths.
*/
import { getModel } from "@mariozechner/pi-ai";
@ -57,8 +53,8 @@ const { session } = await createAgentSession({
Available: read, bash. Be concise.`,
// Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)],
// Extensions are loaded from disk - use additionalExtensionPaths to add custom ones
// additionalExtensionPaths: ["./my-extension.ts"],
// Pass empty array to disable extension discovery, or provide inline factories
extensions: [],
skills: [],
contextFiles: [],
promptTemplates: [],

View file

@ -49,11 +49,13 @@
"glob": "^11.0.3",
"jiti": "^2.6.1",
"marked": "^15.0.12",
"proper-lockfile": "^4.1.2",
"sharp": "^0.34.2"
},
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/node": "^24.3.0",
"@types/proper-lockfile": "^4.1.4",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
},

View file

@ -1,6 +1,9 @@
/**
* Credential storage for API keys and OAuth tokens.
* Handles loading, saving, and refreshing credentials from auth.json.
*
* Uses file locking to prevent race conditions when multiple pi instances
* try to refresh tokens simultaneously.
*/
import {
@ -16,6 +19,7 @@ import {
} from "@mariozechner/pi-ai";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname } from "path";
import lockfile from "proper-lockfile";
export type ApiKeyCredential = {
type: "api_key";
@ -198,12 +202,93 @@ export class AuthStorage {
this.remove(provider);
}
/**
* Refresh OAuth token with file locking to prevent race conditions.
* Multiple pi instances may try to refresh simultaneously when tokens expire.
* This ensures only one instance refreshes while others wait and use the result.
*/
private async refreshOAuthTokenWithLock(
provider: OAuthProvider,
): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
// Ensure auth file exists for locking
if (!existsSync(this.authPath)) {
const dir = dirname(this.authPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
writeFileSync(this.authPath, "{}", "utf-8");
chmodSync(this.authPath, 0o600);
}
let release: (() => Promise<void>) | undefined;
try {
// Acquire exclusive lock with retry and timeout
// Use generous retry window to handle slow token endpoints
release = await lockfile.lock(this.authPath, {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10000,
randomize: true,
},
stale: 30000, // Consider lock stale after 30 seconds
});
// Re-read file after acquiring lock - another instance may have refreshed
this.reload();
const cred = this.data[provider];
if (cred?.type !== "oauth") {
return null;
}
// Check if token is still expired after re-reading
// (another instance may have already refreshed it)
if (Date.now() < cred.expires) {
// Token is now valid - another instance refreshed it
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
const apiKey = needsProjectId
? JSON.stringify({ token: cred.access, projectId: cred.projectId })
: cred.access;
return { apiKey, newCredentials: cred };
}
// Token still expired, we need to refresh
const oauthCreds: Record<string, OAuthCredentials> = {};
for (const [key, value] of Object.entries(this.data)) {
if (value.type === "oauth") {
oauthCreds[key] = value;
}
}
const result = await getOAuthApiKey(provider, oauthCreds);
if (result) {
this.data[provider] = { type: "oauth", ...result.newCredentials };
this.save();
return result;
}
return null;
} finally {
// Always release the lock
if (release) {
try {
await release();
} catch {
// Ignore unlock errors (lock may have been compromised)
}
}
}
}
/**
* Get API key for a provider.
* Priority:
* 1. Runtime override (CLI --api-key)
* 2. API key from auth.json
* 3. OAuth token from auth.json (auto-refreshed)
* 3. OAuth token from auth.json (auto-refreshed with locking)
* 4. Environment variable
* 5. Fallback resolver (models.json custom providers)
*/
@ -221,23 +306,41 @@ export class AuthStorage {
}
if (cred?.type === "oauth") {
// Filter to only oauth credentials for getOAuthApiKey
const oauthCreds: Record<string, OAuthCredentials> = {};
for (const [key, value] of Object.entries(this.data)) {
if (value.type === "oauth") {
oauthCreds[key] = value;
}
}
// Check if token needs refresh
const needsRefresh = Date.now() >= cred.expires;
try {
const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
if (result) {
this.data[provider] = { type: "oauth", ...result.newCredentials };
this.save();
return result.apiKey;
if (needsRefresh) {
// Use locked refresh to prevent race conditions
try {
const result = await this.refreshOAuthTokenWithLock(provider as OAuthProvider);
if (result) {
return result.apiKey;
}
} catch (err) {
// Refresh failed - re-read file to check if another instance succeeded
this.reload();
const updatedCred = this.data[provider];
if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) {
// Another instance refreshed successfully, use those credentials
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
return needsProjectId
? JSON.stringify({ token: updatedCred.access, projectId: updatedCred.projectId })
: updatedCred.access;
}
// Refresh truly failed - DO NOT remove credentials
// User can retry or re-authenticate manually
const errorMessage = err instanceof Error ? err.message : String(err);
throw new Error(
`OAuth token refresh failed for ${provider}: ${errorMessage}. ` +
`Please try again or re-authenticate with /login.`,
);
}
} catch {
this.remove(provider);
} else {
// Token not expired, use current access token
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
return needsProjectId ? JSON.stringify({ token: cred.access, projectId: cred.projectId }) : cred.access;
}
}

View file

@ -444,6 +444,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
errors: [],
setUIContext: () => {},
};
} else if (options.extensions !== undefined) {
// User explicitly provided extensions array (even if empty) - skip discovery
// Inline factories from options.extensions are loaded below
extensionsResult = {
extensions: [],
errors: [],
setUIContext: () => {},
};
} else {
// Discover extensions, merging with additional paths
const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])];