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

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 ?? [])];