mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 16:04:03 +00:00
Merge main into fix/settings-extensions-loading, resolve CHANGELOG conflict
This commit is contained in:
commit
4a7e8672af
9 changed files with 286 additions and 24 deletions
62
package-lock.json
generated
62
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
83
packages/coding-agent/examples/extensions/claude-rules.ts
Normal file
83
packages/coding-agent/examples/extensions/claude-rules.ts
Normal 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.
|
||||
`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? [])];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue