@oh-my-pi/pi-coding-agent 1.338.0 → 1.341.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +60 -1
  2. package/package.json +3 -3
  3. package/src/cli/args.ts +8 -0
  4. package/src/core/agent-session.ts +32 -14
  5. package/src/core/export-html/index.ts +48 -15
  6. package/src/core/export-html/template.html +3 -11
  7. package/src/core/mcp/client.ts +43 -16
  8. package/src/core/mcp/config.ts +152 -6
  9. package/src/core/mcp/index.ts +6 -2
  10. package/src/core/mcp/loader.ts +30 -3
  11. package/src/core/mcp/manager.ts +69 -10
  12. package/src/core/mcp/types.ts +9 -3
  13. package/src/core/model-resolver.ts +101 -0
  14. package/src/core/sdk.ts +65 -18
  15. package/src/core/session-manager.ts +117 -14
  16. package/src/core/settings-manager.ts +107 -19
  17. package/src/core/title-generator.ts +94 -0
  18. package/src/core/tools/bash.ts +1 -2
  19. package/src/core/tools/edit-diff.ts +2 -2
  20. package/src/core/tools/edit.ts +43 -5
  21. package/src/core/tools/grep.ts +3 -2
  22. package/src/core/tools/index.ts +73 -13
  23. package/src/core/tools/lsp/client.ts +45 -20
  24. package/src/core/tools/lsp/config.ts +708 -34
  25. package/src/core/tools/lsp/index.ts +423 -23
  26. package/src/core/tools/lsp/types.ts +5 -0
  27. package/src/core/tools/task/bundled-agents/explore.md +1 -1
  28. package/src/core/tools/task/bundled-agents/reviewer.md +1 -1
  29. package/src/core/tools/task/model-resolver.ts +52 -3
  30. package/src/core/tools/write.ts +67 -4
  31. package/src/index.ts +5 -0
  32. package/src/main.ts +23 -2
  33. package/src/modes/interactive/components/model-selector.ts +96 -18
  34. package/src/modes/interactive/components/session-selector.ts +20 -7
  35. package/src/modes/interactive/components/settings-defs.ts +59 -2
  36. package/src/modes/interactive/components/settings-selector.ts +8 -11
  37. package/src/modes/interactive/components/tool-execution.ts +18 -0
  38. package/src/modes/interactive/components/tree-selector.ts +2 -2
  39. package/src/modes/interactive/components/welcome.ts +40 -3
  40. package/src/modes/interactive/interactive-mode.ts +87 -10
  41. package/src/core/export-html/vendor/highlight.min.js +0 -1213
  42. package/src/core/export-html/vendor/marked.min.js +0 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.341.0] - 2026-01-03
6
+ ### Added
7
+
8
+ - Added interruptMode setting to control when queued messages are processed during tool execution.
9
+ - Implemented getter and setter methods in SettingsManager for interrupt mode persistence.
10
+ - Exposed interruptMode configuration in interactive settings UI with immediate/wait options.
11
+ - Wired interrupt mode through AgentSession and SDK to enable runtime configuration.
12
+ - Model roles: Configure different models for different purposes (default, smol, slow) via `/model` selector
13
+ - Model selector key bindings: Enter sets default, S sets smol, L sets slow, Escape closes
14
+ - Model selector shows role markers: ✓ for default, ⚡ for smol, 🧠 for slow
15
+ - `pi/<role>` model aliases in Task tool agent definitions (e.g., `model: pi/smol, haiku, flash, mini`)
16
+ - Smol model auto-discovery using priority chain: haiku > flash > mini
17
+ - Slow model auto-discovery using priority chain: gpt-5.2-codex > codex > gpt > opus > pro
18
+ - CLI args for model roles: `--smol <model>` and `--slow <model>` (ephemeral, not persisted)
19
+ - Env var overrides: `PI_SMOL_MODEL` and `PI_SLOW_MODEL`
20
+ - Title generation now uses configured smol model from settings
21
+ - LSP diagnostics on edit: Edit tool can now return LSP diagnostics after editing code files. Disabled by default to avoid noise during multi-edit sequences. Enable via `lsp.diagnosticsOnEdit` setting.
22
+ - LSP workspace diagnostics: New `lsp action=workspace_diagnostics` command checks the entire project for errors. Auto-detects project type and uses appropriate checker (rust-analyzer/cargo for Rust, tsc for TypeScript, go build for Go, pyright for Python).
23
+ - LSP local binary resolution: LSP servers installed in project-local directories are now discovered automatically. Checks `node_modules/.bin/` for Node.js projects, `.venv/bin/`/`venv/bin/` for Python projects, and `vendor/bundle/bin/` for Ruby projects before falling back to `$PATH`.
24
+ - LSP format on write: Write tool now automatically formats code files using LSP after writing. Uses the language server's built-in formatter (e.g., rustfmt for Rust, gofmt for Go). Controlled via `lsp.formatOnWrite` setting (enabled by default).
25
+ - LSP diagnostics on write: Write tool now returns LSP diagnostics (errors/warnings) after writing code files. This gives immediate feedback on syntax errors and type issues. Controlled via `lsp.diagnosticsOnWrite` setting (enabled by default).
26
+ - LSP server warmup at startup: LSP servers are now started at launch to avoid cold-start delays when first writing files.
27
+ - LSP server status in welcome banner: Shows which language servers are active and ready.
28
+ - Edit fuzzy match setting: Added `edit.fuzzyMatch` setting (enabled by default) to control whether the edit tool accepts high-confidence fuzzy matches for whitespace/indentation differences. Toggle via `/settings`.
29
+ - Multi-server LSP diagnostics: Diagnostics now query all applicable language servers for a file type. For TypeScript/JavaScript projects with Biome, this means both type errors (from tsserver) and lint errors (from Biome) are reported together.
30
+ - Comprehensive LSP server configurations for 40+ languages including Rust, Go, Python, Java, Kotlin, Scala, Haskell, OCaml, Elixir, Ruby, PHP, C#, Lua, Nix, and many more. Each server includes sensible defaults for args, settings, and init options.
31
+ - Extended LSP config file search paths: Now searches for `lsp.json`, `.lsp.json` in project root and `.pi/` subdirectory, plus user-level configs in `~/.pi/` and home directory.
32
+
33
+ ### Changed
34
+
35
+ - LSP settings moved to dedicated "LSP" tab in `/settings` for better organization
36
+ - Improved grep tool description to document pagination options (`headLimit`, `offset`) and clarify recursive search behavior
37
+ - LSP idle timeout now disabled by default. Configure via `idleTimeoutMs` in lsp.json to auto-shutdown inactive servers.
38
+ - Model settings now use role-based storage (`modelRoles` map) instead of single `defaultProvider`/`defaultModel` fields. Supports multiple model roles (default, small, etc.)
39
+ - Session model persistence now uses `"provider/modelId"` string format with optional role field
40
+
41
+ ### Fixed
42
+
43
+ - Recent sessions now show in welcome banner (was never wired up).
44
+ - Auto-generated session titles: Sessions are now automatically titled based on the first message using a small model (Haiku/GPT-4o-mini/Flash). Titles are shown in the terminal window title, recent sessions list, and --resume picker. The resume picker shows title with dimmed first message preview below.
45
+
46
+ ## [1.340.0] - 2026-01-03
47
+
48
+ ### Changed
49
+
50
+ - Replaced vendored highlight.js and marked.js with CDN-hosted versions for smaller exports
51
+ - Added runtime minification for HTML, CSS, and JS in session exports
52
+ - Session share URL now uses gistpreview.github.io instead of shittycodingagent.ai
53
+
54
+ ## [1.339.0] - 2026-01-03
55
+
56
+ ### Added
57
+
58
+ - MCP project config setting to disable loading `.mcp.json`/`mcp.json` from project root
59
+ - Support for both `mcp.json` and `.mcp.json` filenames (prefers `mcp.json` if both exist)
60
+ - Automatic Exa MCP server filtering with API key extraction for native integration
61
+
3
62
  ## [1.338.0] - 2026-01-03
4
63
 
5
64
  ### Added
@@ -1268,4 +1327,4 @@ Initial public release.
1268
1327
  - Git branch display in footer
1269
1328
  - Message queueing during streaming responses
1270
1329
  - OAuth integration for Gmail and Google Calendar access
1271
- - HTML export with syntax highlighting and collapsible sections
1330
+ - HTML export with syntax highlighting and collapsible sections
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "1.338.0",
3
+ "version": "1.341.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -33,8 +33,8 @@
33
33
  "clean": "rm -rf dist",
34
34
  "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
35
35
  "build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
36
- "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html/vendor && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
37
- "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html/vendor && cp src/core/export-html/template.html dist/export-html/ && cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && cp -r docs dist/ && cp -r examples dist/",
36
+ "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/",
37
+ "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/export-html/ && cp -r docs dist/ && cp -r examples dist/",
38
38
  "test": "vitest --run",
39
39
  "prepublishOnly": "npm run clean && npm run build"
40
40
  },
package/src/cli/args.ts CHANGED
@@ -12,6 +12,8 @@ export type Mode = "text" | "json" | "rpc";
12
12
  export interface Args {
13
13
  provider?: string;
14
14
  model?: string;
15
+ smol?: string;
16
+ slow?: string;
15
17
  apiKey?: string;
16
18
  systemPrompt?: string;
17
19
  appendSystemPrompt?: string;
@@ -69,6 +71,10 @@ export function parseArgs(args: string[]): Args {
69
71
  result.provider = args[++i];
70
72
  } else if (arg === "--model" && i + 1 < args.length) {
71
73
  result.model = args[++i];
74
+ } else if (arg === "--smol" && i + 1 < args.length) {
75
+ result.smol = args[++i];
76
+ } else if (arg === "--slow" && i + 1 < args.length) {
77
+ result.slow = args[++i];
72
78
  } else if (arg === "--api-key" && i + 1 < args.length) {
73
79
  result.apiKey = args[++i];
74
80
  } else if (arg === "--system-prompt" && i + 1 < args.length) {
@@ -148,6 +154,8 @@ ${chalk.bold("Usage:")}
148
154
  ${chalk.bold("Options:")}
149
155
  --provider <name> Provider name (default: google)
150
156
  --model <id> Model ID (default: gemini-2.5-flash)
157
+ --smol <id> Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)
158
+ --slow <id> Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)
151
159
  --api-key <key> API key (defaults to env vars)
152
160
  --system-prompt <text> System prompt (default: coding assistant prompt)
153
161
  --append-system-prompt <text> Append text or file contents to the system prompt
@@ -424,6 +424,11 @@ export class AgentSession {
424
424
  return this.agent.getQueueMode();
425
425
  }
426
426
 
427
+ /** Current interrupt mode */
428
+ get interruptMode(): "immediate" | "wait" {
429
+ return this.agent.getInterruptMode();
430
+ }
431
+
427
432
  /** Current session file path, or undefined if sessions are disabled */
428
433
  get sessionFile(): string | undefined {
429
434
  return this.sessionManager.getSessionFile();
@@ -701,15 +706,15 @@ export class AgentSession {
701
706
  * Validates API key, saves to session and settings.
702
707
  * @throws Error if no API key available for the model
703
708
  */
704
- async setModel(model: Model<any>): Promise<void> {
709
+ async setModel(model: Model<any>, role: string = "default"): Promise<void> {
705
710
  const apiKey = await this._modelRegistry.getApiKey(model);
706
711
  if (!apiKey) {
707
712
  throw new Error(`No API key for ${model.provider}/${model.id}`);
708
713
  }
709
714
 
710
715
  this.agent.setModel(model);
711
- this.sessionManager.appendModelChange(model.provider, model.id);
712
- this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
716
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
717
+ this.settingsManager.setModelRole(role, `${model.provider}/${model.id}`);
713
718
 
714
719
  // Re-clamp thinking level for new model's capabilities
715
720
  this.setThinkingLevel(this.thinkingLevel);
@@ -747,8 +752,8 @@ export class AgentSession {
747
752
 
748
753
  // Apply model
749
754
  this.agent.setModel(next.model);
750
- this.sessionManager.appendModelChange(next.model.provider, next.model.id);
751
- this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
755
+ this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
756
+ this.settingsManager.setModelRole("default", `${next.model.provider}/${next.model.id}`);
752
757
 
753
758
  // Apply thinking level (setThinkingLevel clamps to model capabilities)
754
759
  this.setThinkingLevel(next.thinkingLevel);
@@ -774,8 +779,8 @@ export class AgentSession {
774
779
  }
775
780
 
776
781
  this.agent.setModel(nextModel);
777
- this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
778
- this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
782
+ this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
783
+ this.settingsManager.setModelRole("default", `${nextModel.provider}/${nextModel.id}`);
779
784
 
780
785
  // Re-clamp thinking level for new model's capabilities
781
786
  this.setThinkingLevel(this.thinkingLevel);
@@ -861,6 +866,15 @@ export class AgentSession {
861
866
  this.settingsManager.setQueueMode(mode);
862
867
  }
863
868
 
869
+ /**
870
+ * Set interrupt mode.
871
+ * Saves to settings.
872
+ */
873
+ setInterruptMode(mode: "immediate" | "wait"): void {
874
+ this.agent.setInterruptMode(mode);
875
+ this.settingsManager.setInterruptMode(mode);
876
+ }
877
+
864
878
  // =========================================================================
865
879
  // Compaction
866
880
  // =========================================================================
@@ -1472,13 +1486,17 @@ export class AgentSession {
1472
1486
  this.agent.replaceMessages(sessionContext.messages);
1473
1487
 
1474
1488
  // Restore model if saved
1475
- if (sessionContext.model) {
1476
- const availableModels = await this._modelRegistry.getAvailable();
1477
- const match = availableModels.find(
1478
- (m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
1479
- );
1480
- if (match) {
1481
- this.agent.setModel(match);
1489
+ const defaultModelStr = sessionContext.models.default;
1490
+ if (defaultModelStr) {
1491
+ const slashIdx = defaultModelStr.indexOf("/");
1492
+ if (slashIdx > 0) {
1493
+ const provider = defaultModelStr.slice(0, slashIdx);
1494
+ const modelId = defaultModelStr.slice(slashIdx + 1);
1495
+ const availableModels = await this._modelRegistry.getAvailable();
1496
+ const match = availableModels.find((m) => m.provider === provider && m.id === modelId);
1497
+ if (match) {
1498
+ this.agent.setModel(match);
1499
+ }
1482
1500
  }
1483
1501
  }
1484
1502
 
@@ -5,6 +5,34 @@ import { APP_NAME, getExportTemplateDir } from "../../config.js";
5
5
  import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
6
6
  import { SessionManager } from "../session-manager.js";
7
7
 
8
+ // Cached minified assets (populated on first use)
9
+ let cachedTemplate: string | null = null;
10
+ let cachedJs: string | null = null;
11
+
12
+ /** Minify CSS by removing comments, unnecessary whitespace, and newlines. */
13
+ function minifyCss(css: string): string {
14
+ return css
15
+ .replace(/\/\*[\s\S]*?\*\//g, "") // Remove comments
16
+ .replace(/\s+/g, " ") // Collapse whitespace
17
+ .replace(/\s*([{}:;,>+~])\s*/g, "$1") // Remove space around punctuation
18
+ .replace(/;}/g, "}") // Remove trailing semicolons
19
+ .trim();
20
+ }
21
+
22
+ /** Minify JS using Bun's transpiler. */
23
+ function minifyJs(js: string): string {
24
+ const transpiler = new Bun.Transpiler({ loader: "js", minifyWhitespace: true });
25
+ return transpiler.transformSync(js);
26
+ }
27
+
28
+ /** Minify HTML by collapsing whitespace outside of tags. */
29
+ function minifyHtml(html: string): string {
30
+ return html
31
+ .replace(/>\s+</g, "><") // Remove whitespace between tags
32
+ .replace(/\s{2,}/g, " ") // Collapse multiple spaces
33
+ .trim();
34
+ }
35
+
8
36
  export interface ExportOptions {
9
37
  outputPath?: string;
10
38
  themeName?: string;
@@ -111,11 +139,16 @@ interface SessionData {
111
139
  */
112
140
  function generateHtml(sessionData: SessionData, themeName?: string): string {
113
141
  const templateDir = getExportTemplateDir();
114
- const template = readFileSync(join(templateDir, "template.html"), "utf-8");
142
+
143
+ // Load and minify assets on first use
144
+ if (!cachedTemplate) {
145
+ cachedTemplate = minifyHtml(readFileSync(join(templateDir, "template.html"), "utf-8"));
146
+ }
147
+ if (!cachedJs) {
148
+ cachedJs = minifyJs(readFileSync(join(templateDir, "template.js"), "utf-8"));
149
+ }
150
+
115
151
  const templateCss = readFileSync(join(templateDir, "template.css"), "utf-8");
116
- const templateJs = readFileSync(join(templateDir, "template.js"), "utf-8");
117
- const markedJs = readFileSync(join(templateDir, "vendor", "marked.min.js"), "utf-8");
118
- const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8");
119
152
 
120
153
  const themeVars = generateThemeVars(themeName);
121
154
  const colors = getResolvedThemeColors(themeName);
@@ -127,19 +160,19 @@ function generateHtml(sessionData: SessionData, themeName?: string): string {
127
160
  // Base64 encode session data to avoid escaping issues
128
161
  const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64");
129
162
 
130
- // Build the CSS with theme variables injected
131
- const css = templateCss
132
- .replace("{{THEME_VARS}}", themeVars)
133
- .replace("{{BODY_BG}}", bodyBg)
134
- .replace("{{CONTAINER_BG}}", containerBg)
135
- .replace("{{INFO_BG}}", infoBg);
163
+ // Build and minify the CSS with theme variables injected
164
+ const css = minifyCss(
165
+ templateCss
166
+ .replace("{{THEME_VARS}}", themeVars)
167
+ .replace("{{BODY_BG}}", bodyBg)
168
+ .replace("{{CONTAINER_BG}}", containerBg)
169
+ .replace("{{INFO_BG}}", infoBg),
170
+ );
136
171
 
137
- return template
172
+ return cachedTemplate
138
173
  .replace("{{CSS}}", css)
139
- .replace("{{JS}}", templateJs)
140
- .replace("{{SESSION_DATA}}", sessionDataBase64)
141
- .replace("{{MARKED_JS}}", markedJs)
142
- .replace("{{HIGHLIGHT_JS}}", hljsJs);
174
+ .replace("{{JS}}", cachedJs)
175
+ .replace("{{SESSION_DATA}}", sessionDataBase64);
143
176
  }
144
177
 
145
178
  /**
@@ -39,16 +39,8 @@
39
39
  </div>
40
40
 
41
41
  <script id="session-data" type="application/json">{{SESSION_DATA}}</script>
42
-
43
- <!-- Vendored libraries -->
44
- <script>{{MARKED_JS}}</script>
45
-
46
- <!-- highlight.js -->
47
- <script>{{HIGHLIGHT_JS}}</script>
48
-
49
- <!-- Main application code -->
50
- <script>
51
- {{JS}}
52
- </script>
42
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.4/marked.min.js" integrity="sha512-VmLxPVdDGeR+F0DzUHVqzHwaR4ZSSh1g/7aYXwKT1PAGVxunOEcysta+4H5Utvmpr2xExEPybZ8q+iM9F1tGdw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
43
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
44
+ <script>{{JS}}</script>
53
45
  </body>
54
46
  </html>
@@ -25,12 +25,32 @@ import type {
25
25
  /** MCP protocol version we support */
26
26
  const PROTOCOL_VERSION = "2025-03-26";
27
27
 
28
+ /** Default connection timeout in ms */
29
+ const CONNECTION_TIMEOUT_MS = 30_000;
30
+
28
31
  /** Client info sent during initialization */
29
32
  const CLIENT_INFO = {
30
33
  name: "pi-coding-agent",
31
34
  version: "1.0.0",
32
35
  };
33
36
 
37
+ /** Wrap a promise with a timeout */
38
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
39
+ return new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => reject(new Error(message)), ms);
41
+ promise.then(
42
+ (value) => {
43
+ clearTimeout(timer);
44
+ resolve(value);
45
+ },
46
+ (error) => {
47
+ clearTimeout(timer);
48
+ reject(error);
49
+ },
50
+ );
51
+ });
52
+ }
53
+
34
54
  /**
35
55
  * Create a transport for the given server config.
36
56
  */
@@ -73,24 +93,31 @@ async function initializeConnection(transport: MCPTransport): Promise<MCPInitial
73
93
 
74
94
  /**
75
95
  * Connect to an MCP server.
96
+ * Has a 30 second timeout to prevent blocking startup.
76
97
  */
77
98
  export async function connectToServer(name: string, config: MCPServerConfig): Promise<MCPServerConnection> {
78
- const transport = await createTransport(config);
79
-
80
- try {
81
- const initResult = await initializeConnection(transport);
82
-
83
- return {
84
- name,
85
- config,
86
- transport,
87
- serverInfo: initResult.serverInfo,
88
- capabilities: initResult.capabilities,
89
- };
90
- } catch (error) {
91
- await transport.close();
92
- throw error;
93
- }
99
+ const timeoutMs = config.timeout ?? CONNECTION_TIMEOUT_MS;
100
+
101
+ const connect = async (): Promise<MCPServerConnection> => {
102
+ const transport = await createTransport(config);
103
+
104
+ try {
105
+ const initResult = await initializeConnection(transport);
106
+
107
+ return {
108
+ name,
109
+ config,
110
+ transport,
111
+ serverInfo: initResult.serverInfo,
112
+ capabilities: initResult.capabilities,
113
+ };
114
+ } catch (error) {
115
+ await transport.close();
116
+ throw error;
117
+ }
118
+ };
119
+
120
+ return withTimeout(connect(), timeoutMs, `Connection to MCP server "${name}" timed out after ${timeoutMs}ms`);
94
121
  }
95
122
 
96
123
  /**
@@ -91,11 +91,17 @@ export interface MCPConfigLocations {
91
91
  */
92
92
  export function getMCPConfigPaths(cwd: string): MCPConfigLocations {
93
93
  const home = homedir();
94
+
95
+ // Project-level: check both mcp.json and .mcp.json (prefer mcp.json if both exist)
96
+ const mcpJson = join(cwd, "mcp.json");
97
+ const dotMcpJson = join(cwd, ".mcp.json");
98
+ const projectPath = existsSync(mcpJson) ? mcpJson : dotMcpJson;
99
+
94
100
  return {
95
101
  // User-level: ~/.pi/mcp.json (our standard)
96
102
  user: join(home, ".pi", "mcp.json"),
97
- // Project-level: .mcp.json at project root
98
- project: join(cwd, ".mcp.json"),
103
+ // Project-level: mcp.json or .mcp.json at project root
104
+ project: projectPath,
99
105
  };
100
106
  }
101
107
 
@@ -115,17 +121,157 @@ export function mergeMCPConfigs(...configs: (MCPConfigFile | null)[]): Record<st
115
121
  return result;
116
122
  }
117
123
 
124
+ /** Options for loading MCP configs */
125
+ export interface LoadMCPConfigsOptions {
126
+ /** Additional environment variables for expansion */
127
+ extraEnv?: Record<string, string>;
128
+ /** Whether to load project-level config (default: true) */
129
+ enableProjectConfig?: boolean;
130
+ /** Whether to filter out Exa MCP servers (default: true) */
131
+ filterExa?: boolean;
132
+ }
133
+
134
+ /** Result of loading MCP configs */
135
+ export interface LoadMCPConfigsResult {
136
+ /** Loaded server configs */
137
+ configs: Record<string, MCPServerConfig>;
138
+ /** Extracted Exa API keys (if any were filtered) */
139
+ exaApiKeys: string[];
140
+ }
141
+
118
142
  /**
119
143
  * Load all MCP server configs from standard locations.
120
144
  * Returns merged config with project overriding user.
145
+ *
146
+ * @param cwd Working directory (project root)
147
+ * @param options Load options or extraEnv for backwards compatibility
121
148
  */
122
- export function loadAllMCPConfigs(cwd: string, extraEnv?: Record<string, string>): Record<string, MCPServerConfig> {
149
+ export function loadAllMCPConfigs(
150
+ cwd: string,
151
+ options?: LoadMCPConfigsOptions | Record<string, string>,
152
+ ): LoadMCPConfigsResult {
153
+ // Support old signature: loadAllMCPConfigs(cwd, extraEnv)
154
+ const opts: LoadMCPConfigsOptions =
155
+ options && ("extraEnv" in options || "enableProjectConfig" in options || "filterExa" in options)
156
+ ? (options as LoadMCPConfigsOptions)
157
+ : { extraEnv: options as Record<string, string> | undefined };
158
+
159
+ const enableProjectConfig = opts.enableProjectConfig ?? true;
160
+ const filterExa = opts.filterExa ?? true;
161
+
123
162
  const paths = getMCPConfigPaths(cwd);
124
163
 
125
- const userConfig = paths.user ? loadMCPConfigFile(paths.user, extraEnv) : null;
126
- const projectConfig = paths.project ? loadMCPConfigFile(paths.project, extraEnv) : null;
164
+ const userConfig = paths.user ? loadMCPConfigFile(paths.user, opts.extraEnv) : null;
165
+ const projectConfig = enableProjectConfig && paths.project ? loadMCPConfigFile(paths.project, opts.extraEnv) : null;
166
+
167
+ let configs = mergeMCPConfigs(userConfig, projectConfig);
168
+ let exaApiKeys: string[] = [];
169
+
170
+ if (filterExa) {
171
+ const result = filterExaMCPServers(configs);
172
+ configs = result.configs;
173
+ exaApiKeys = result.exaApiKeys;
174
+ }
175
+
176
+ return { configs, exaApiKeys };
177
+ }
178
+
179
+ /** Pattern to match Exa MCP servers */
180
+ const EXA_MCP_URL_PATTERN = /mcp\.exa\.ai/i;
181
+ const EXA_API_KEY_PATTERN = /exaApiKey=([^&\s]+)/i;
182
+
183
+ /**
184
+ * Check if a server config is an Exa MCP server.
185
+ */
186
+ export function isExaMCPServer(name: string, config: MCPServerConfig): boolean {
187
+ // Check by server name
188
+ if (name.toLowerCase() === "exa") {
189
+ return true;
190
+ }
191
+
192
+ // Check by URL for HTTP/SSE servers
193
+ if (config.type === "http" || config.type === "sse") {
194
+ const httpConfig = config as { url?: string };
195
+ if (httpConfig.url && EXA_MCP_URL_PATTERN.test(httpConfig.url)) {
196
+ return true;
197
+ }
198
+ }
199
+
200
+ // Check by args for stdio servers (e.g., mcp-remote to exa)
201
+ if (!config.type || config.type === "stdio") {
202
+ const stdioConfig = config as { args?: string[] };
203
+ if (stdioConfig.args?.some((arg) => EXA_MCP_URL_PATTERN.test(arg))) {
204
+ return true;
205
+ }
206
+ }
207
+
208
+ return false;
209
+ }
210
+
211
+ /**
212
+ * Extract Exa API key from an MCP server config.
213
+ */
214
+ export function extractExaApiKey(config: MCPServerConfig): string | undefined {
215
+ // Check URL for HTTP/SSE servers
216
+ if (config.type === "http" || config.type === "sse") {
217
+ const httpConfig = config as { url?: string };
218
+ if (httpConfig.url) {
219
+ const match = EXA_API_KEY_PATTERN.exec(httpConfig.url);
220
+ if (match) return match[1];
221
+ }
222
+ }
223
+
224
+ // Check args for stdio servers
225
+ if (!config.type || config.type === "stdio") {
226
+ const stdioConfig = config as { args?: string[] };
227
+ if (stdioConfig.args) {
228
+ for (const arg of stdioConfig.args) {
229
+ const match = EXA_API_KEY_PATTERN.exec(arg);
230
+ if (match) return match[1];
231
+ }
232
+ }
233
+ }
234
+
235
+ // Check env vars
236
+ if ("env" in config && config.env) {
237
+ const envConfig = config as { env: Record<string, string> };
238
+ if (envConfig.env.EXA_API_KEY) {
239
+ return envConfig.env.EXA_API_KEY;
240
+ }
241
+ }
242
+
243
+ return undefined;
244
+ }
245
+
246
+ /** Result of filtering Exa MCP servers */
247
+ export interface ExaFilterResult {
248
+ /** Configs with Exa servers removed */
249
+ configs: Record<string, MCPServerConfig>;
250
+ /** Extracted Exa API keys (if any) */
251
+ exaApiKeys: string[];
252
+ }
253
+
254
+ /**
255
+ * Filter out Exa MCP servers and extract their API keys.
256
+ * Since we have native Exa integration, we don't need the MCP server.
257
+ */
258
+ export function filterExaMCPServers(configs: Record<string, MCPServerConfig>): ExaFilterResult {
259
+ const filtered: Record<string, MCPServerConfig> = {};
260
+ const exaApiKeys: string[] = [];
261
+
262
+ for (const [name, config] of Object.entries(configs)) {
263
+ if (isExaMCPServer(name, config)) {
264
+ // Extract API key before filtering
265
+ const apiKey = extractExaApiKey(config);
266
+ if (apiKey) {
267
+ exaApiKeys.push(apiKey);
268
+ }
269
+ } else {
270
+ filtered[name] = config;
271
+ }
272
+ }
127
273
 
128
- return mergeMCPConfigs(userConfig, projectConfig);
274
+ return { configs: filtered, exaApiKeys };
129
275
  }
130
276
 
131
277
  /**
@@ -9,19 +9,23 @@
9
9
  export { callTool, connectToServer, disconnectServer, listTools, serverSupportsTools } from "./client.js";
10
10
 
11
11
  // Config
12
+ export type { ExaFilterResult, LoadMCPConfigsOptions, LoadMCPConfigsResult } from "./config.js";
12
13
  export {
13
14
  expandEnvVars,
15
+ extractExaApiKey,
16
+ filterExaMCPServers,
14
17
  getMCPConfigPaths,
18
+ isExaMCPServer,
15
19
  loadAllMCPConfigs,
16
20
  loadMCPConfigFile,
17
21
  mergeMCPConfigs,
18
22
  validateServerConfig,
19
23
  } from "./config.js";
20
24
  // Loader (for SDK integration)
21
- export type { MCPToolsLoadResult } from "./loader.js";
25
+ export type { MCPToolsLoadOptions, MCPToolsLoadResult } from "./loader.js";
22
26
  export { discoverAndLoadMCPTools } from "./loader.js";
23
27
  // Manager
24
- export type { MCPLoadResult } from "./manager.js";
28
+ export type { MCPDiscoverOptions, MCPLoadResult } from "./manager.js";
25
29
  export { createMCPManager, MCPManager } from "./manager.js";
26
30
  // Tool bridge
27
31
  export type { MCPToolDetails } from "./tool-bridge.js";