diff --git a/package-lock.json b/package-lock.json index 16198fe8..1780b4d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fd20638e..ad5d33be 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d8b730ad..33b66b61 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/examples/extensions/claude-rules.ts b/packages/coding-agent/examples/extensions/claude-rules.ts new file mode 100644 index 00000000..747e9e7a --- /dev/null +++ b/packages/coding-agent/examples/extensions/claude-rules.ts @@ -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. +`, + }; + }); +} diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 1edb8245..db981179 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,5 +1,6 @@ { "name": "pi-extension-with-deps", + "private": true, "version": "1.0.0", "type": "module", "pi": { diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index a51c2d37..2d361b8e 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -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: [], diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 51f1da6f..2dfdeceb 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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" }, diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index e76b04c6..78b1029e 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -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) | 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 = {}; + 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 = {}; - 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; } } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e575a485..b89f6e85 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -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 ?? [])];