@oh-my-pi/pi-coding-agent 12.16.0 → 12.17.2

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.
@@ -34,6 +34,8 @@ export interface BashResult {
34
34
  artifactId?: string;
35
35
  }
36
36
 
37
+ const HARD_TIMEOUT_GRACE_MS = 5_000;
38
+
37
39
  const shellSessions = new Map<string, Shell>();
38
40
 
39
41
  export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
@@ -65,74 +67,106 @@ export async function executeBash(command: string, options?: BashExecutorOptions
65
67
  };
66
68
  }
67
69
 
70
+ const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
71
+ let shellSession = shellSessions.get(sessionKey);
72
+ if (!shellSession) {
73
+ shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
74
+ shellSessions.set(sessionKey, shellSession);
75
+ }
76
+ const signal = options?.signal;
77
+ const abortHandler = () => {
78
+ shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined);
79
+ };
80
+ if (signal) {
81
+ signal.addEventListener("abort", abortHandler, { once: true });
82
+ }
83
+
84
+ let hardTimeoutTimer: NodeJS.Timeout | undefined;
85
+ const hardTimeoutDeferred = Promise.withResolvers<"hard-timeout">();
86
+ const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000);
87
+ const hardTimeoutMs = baseTimeoutMs + HARD_TIMEOUT_GRACE_MS;
88
+ hardTimeoutTimer = setTimeout(() => {
89
+ shellSession.abort(`Hard timeout after ${Math.round(hardTimeoutMs / 1000)}s`);
90
+ hardTimeoutDeferred.resolve("hard-timeout");
91
+ }, hardTimeoutMs);
92
+
93
+ let resetSession = false;
94
+
68
95
  try {
69
- const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
70
- let shellSession = shellSessions.get(sessionKey);
71
- if (!shellSession) {
72
- shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
73
- shellSessions.set(sessionKey, shellSession);
96
+ const runPromise = shellSession.run(
97
+ {
98
+ command: finalCommand,
99
+ cwd: options?.cwd,
100
+ env: options?.env,
101
+ timeoutMs: options?.timeout,
102
+ signal,
103
+ },
104
+ (err, chunk) => {
105
+ if (!err) {
106
+ enqueueChunk(chunk);
107
+ }
108
+ },
109
+ );
110
+
111
+ const winner = await Promise.race([
112
+ runPromise.then(result => ({ kind: "result" as const, result })),
113
+ hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })),
114
+ ]);
115
+
116
+ await pendingChunks;
117
+
118
+ if (winner.kind === "hard-timeout") {
119
+ resetSession = true;
120
+ return {
121
+ exitCode: undefined,
122
+ cancelled: true,
123
+ ...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)),
124
+ };
74
125
  }
75
126
 
76
- const signal = options?.signal;
77
- const abortHandler = () => {
78
- shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined);
79
- };
80
- if (signal) {
81
- signal.addEventListener("abort", abortHandler, { once: true });
127
+ // Handle timeout
128
+ if (winner.result.timedOut) {
129
+ const annotation = options?.timeout
130
+ ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
131
+ : "Command timed out";
132
+ resetSession = true;
133
+ return {
134
+ exitCode: undefined,
135
+ cancelled: true,
136
+ ...(await sink.dump(annotation)),
137
+ };
82
138
  }
83
139
 
84
- try {
85
- const result = await shellSession.run(
86
- {
87
- command: finalCommand,
88
- cwd: options?.cwd,
89
- env: options?.env,
90
- timeoutMs: options?.timeout,
91
- signal,
92
- },
93
- (err, chunk) => {
94
- if (!err) {
95
- enqueueChunk(chunk);
96
- }
97
- },
98
- );
99
-
100
- await pendingChunks;
101
-
102
- // Handle timeout
103
- if (result.timedOut) {
104
- const annotation = options?.timeout
105
- ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
106
- : "Command timed out";
107
- return {
108
- exitCode: undefined,
109
- cancelled: true,
110
- ...(await sink.dump(annotation)),
111
- };
112
- }
113
-
114
- // Handle cancellation
115
- if (result.cancelled) {
116
- return {
117
- exitCode: undefined,
118
- cancelled: true,
119
- ...(await sink.dump("Command cancelled")),
120
- };
121
- }
122
-
123
- // Normal completion
140
+ // Handle cancellation
141
+ if (winner.result.cancelled) {
142
+ resetSession = true;
124
143
  return {
125
- exitCode: result.exitCode,
126
- cancelled: false,
127
- ...(await sink.dump()),
144
+ exitCode: undefined,
145
+ cancelled: true,
146
+ ...(await sink.dump("Command cancelled")),
128
147
  };
129
- } finally {
130
- if (signal) {
131
- signal.removeEventListener("abort", abortHandler);
132
- }
133
148
  }
149
+
150
+ // Normal completion
151
+ return {
152
+ exitCode: winner.result.exitCode,
153
+ cancelled: false,
154
+ ...(await sink.dump()),
155
+ };
156
+ } catch (err) {
157
+ resetSession = true;
158
+ throw err;
134
159
  } finally {
160
+ if (hardTimeoutTimer) {
161
+ clearTimeout(hardTimeoutTimer);
162
+ }
163
+ if (signal) {
164
+ signal.removeEventListener("abort", abortHandler);
165
+ }
135
166
  await pendingChunks;
167
+ if (resetSession) {
168
+ shellSessions.delete(sessionKey);
169
+ }
136
170
  }
137
171
  }
138
172
 
@@ -182,7 +182,7 @@ export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
182
182
  /** Called on session lifecycle events - use to reconstruct state or cleanup resources */
183
183
  onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
184
184
  /** Custom rendering for tool call display - return a Component */
185
- renderCall?: (args: Static<TParams>, theme: Theme) => Component;
185
+ renderCall?: (args: Static<TParams>, options: RenderResultOptions, theme: Theme) => Component;
186
186
 
187
187
  /** Custom rendering for tool result display - return a Component */
188
188
  renderResult?: (
@@ -304,7 +304,7 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
304
304
  onSession?: (event: ToolSessionEvent, ctx: ExtensionContext) => void | Promise<void>;
305
305
 
306
306
  /** Custom rendering for tool call display */
307
- renderCall?: (args: Static<TParams>, theme: Theme) => Component;
307
+ renderCall?: (args: Static<TParams>, options: ToolRenderResultOptions, theme: Theme) => Component;
308
308
 
309
309
  /** Custom rendering for tool result display */
310
310
  renderResult?: (
@@ -18,7 +18,7 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
18
18
  declare parameters: any;
19
19
  declare label: string;
20
20
 
21
- renderCall?: (args: any, theme: any) => any;
21
+ renderCall?: (args: any, options: any, theme: any) => any;
22
22
  renderResult?: (result: any, options: any, theme: any, args?: any) => any;
23
23
 
24
24
  constructor(
@@ -32,7 +32,8 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
32
32
  // enters the custom-renderer path, gets undefined back, and silently
33
33
  // discards tool result text (extensions without renderers show blank).
34
34
  if (registeredTool.definition.renderCall) {
35
- this.renderCall = (args: any, theme: any) => registeredTool.definition.renderCall!(args, theme as Theme);
35
+ this.renderCall = (args: any, options: any, theme: any) =>
36
+ registeredTool.definition.renderCall!(args, options, theme as Theme);
36
37
  }
37
38
  if (registeredTool.definition.renderResult) {
38
39
  this.renderResult = (result: any, options: any, theme: any, args?: any) =>
package/src/lsp/render.ts CHANGED
@@ -31,7 +31,7 @@ import type { LspParams, LspToolDetails } from "./types";
31
31
  * Render the LSP tool call in the TUI.
32
32
  * Shows: "lsp <operation> <file/filecount>"
33
33
  */
34
- export function renderCall(args: LspParams, theme: Theme): Text {
34
+ export function renderCall(args: LspParams, _options: RenderResultOptions, theme: Theme): Text {
35
35
  const actionLabel = (args.action ?? "request").replace(/_/g, " ");
36
36
  const queryPreview = args.query ? truncateToWidth(args.query, TRUNCATE_LENGTHS.SHORT) : undefined;
37
37
 
package/src/mcp/config.ts CHANGED
@@ -18,6 +18,8 @@ export interface LoadMCPConfigsOptions {
18
18
  enableProjectConfig?: boolean;
19
19
  /** Whether to filter out Exa MCP servers (default: true) */
20
20
  filterExa?: boolean;
21
+ /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */
22
+ filterBrowser?: boolean;
21
23
  }
22
24
 
23
25
  /** Result of loading MCP configs */
@@ -91,6 +93,7 @@ function convertToLegacyConfig(server: MCPServer): MCPServerConfig {
91
93
  export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOptions): Promise<LoadMCPConfigsResult> {
92
94
  const enableProjectConfig = options?.enableProjectConfig ?? true;
93
95
  const filterExa = options?.filterExa ?? true;
96
+ const filterBrowser = options?.filterBrowser ?? false;
94
97
 
95
98
  // Load MCP servers via capability system
96
99
  const result = await loadCapability<MCPServer>(mcpCapability.id, { cwd });
@@ -103,8 +106,8 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
103
106
  // Load user-level disabled servers list
104
107
  const disabledServers = new Set(await readDisabledServers(getMCPConfigPath("user", cwd)));
105
108
  // Convert to legacy format and preserve source metadata
106
- const configs: Record<string, MCPServerConfig> = {};
107
- const sources: Record<string, SourceMeta> = {};
109
+ let configs: Record<string, MCPServerConfig> = {};
110
+ let sources: Record<string, SourceMeta> = {};
108
111
  for (const server of servers) {
109
112
  const config = convertToLegacyConfig(server);
110
113
  if (config.enabled === false || (server._source.level !== "user" && disabledServers.has(server.name))) {
@@ -114,11 +117,19 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
114
117
  sources[server.name] = server._source;
115
118
  }
116
119
 
117
- const exaApiKeys: string[] = [];
120
+ let exaApiKeys: string[] = [];
118
121
 
119
122
  if (filterExa) {
120
- const filterResult = filterExaMCPServers(configs, sources);
121
- return { configs: filterResult.configs, exaApiKeys: filterResult.exaApiKeys, sources: filterResult.sources };
123
+ const exaResult = filterExaMCPServers(configs, sources);
124
+ configs = exaResult.configs;
125
+ sources = exaResult.sources;
126
+ exaApiKeys = exaResult.exaApiKeys;
127
+ }
128
+
129
+ if (filterBrowser) {
130
+ const browserResult = filterBrowserMCPServers(configs, sources);
131
+ configs = browserResult.configs;
132
+ sources = browserResult.sources;
122
133
  }
123
134
 
124
135
  return { configs, exaApiKeys, sources };
@@ -264,3 +275,89 @@ export function validateServerConfig(name: string, config: MCPServerConfig): str
264
275
 
265
276
  return errors;
266
277
  }
278
+
279
+ /** Known browser automation MCP server names (lowercase) */
280
+ const BROWSER_MCP_NAMES = new Set([
281
+ "puppeteer",
282
+ "playwright",
283
+ "browserbase",
284
+ "browser-tools",
285
+ "browser-use",
286
+ "browser",
287
+ ]);
288
+
289
+ /** Patterns matching browser MCP package names in command/args */
290
+ const BROWSER_MCP_PKG_PATTERN =
291
+ // Official packages
292
+ // - @modelcontextprotocol/server-puppeteer
293
+ // - @playwright/mcp
294
+ // - @browserbasehq/mcp-server-browserbase
295
+ // - @agentdeskai/browser-tools-mcp
296
+ // - @agent-infra/mcp-server-browser
297
+ // Community packages: puppeteer-mcp-server, playwright-mcp, pptr-mcp, etc.
298
+ /(?:@modelcontextprotocol\/server-puppeteer|@playwright\/mcp|@browserbasehq\/mcp-server-browserbase|@agentdeskai\/browser-tools-mcp|@agent-infra\/mcp-server-browser|puppeteer-mcp|playwright-mcp|pptr-mcp|browser-use-mcp|mcp-browser-use)/i;
299
+
300
+ /** URL patterns for hosted browser MCP services */
301
+ const BROWSER_MCP_URL_PATTERN = /browserbase\.com|browser-use\.com/i;
302
+
303
+ /**
304
+ * Check if a server config is a browser automation MCP server.
305
+ */
306
+ export function isBrowserMCPServer(name: string, config: MCPServerConfig): boolean {
307
+ // Check by server name
308
+ if (BROWSER_MCP_NAMES.has(name.toLowerCase())) {
309
+ return true;
310
+ }
311
+
312
+ // Check by URL for HTTP/SSE servers
313
+ if (config.type === "http" || config.type === "sse") {
314
+ const httpConfig = config as { url?: string };
315
+ if (httpConfig.url && BROWSER_MCP_URL_PATTERN.test(httpConfig.url)) {
316
+ return true;
317
+ }
318
+ }
319
+
320
+ // Check by command/args for stdio servers
321
+ if (!config.type || config.type === "stdio") {
322
+ const stdioConfig = config as { command?: string; args?: string[] };
323
+ if (stdioConfig.command && BROWSER_MCP_PKG_PATTERN.test(stdioConfig.command)) {
324
+ return true;
325
+ }
326
+ if (stdioConfig.args?.some(arg => BROWSER_MCP_PKG_PATTERN.test(arg))) {
327
+ return true;
328
+ }
329
+ }
330
+
331
+ return false;
332
+ }
333
+
334
+ /** Result of filtering browser MCP servers */
335
+ export interface BrowserFilterResult {
336
+ /** Configs with browser servers removed */
337
+ configs: Record<string, MCPServerConfig>;
338
+ /** Source metadata for remaining servers */
339
+ sources: Record<string, SourceMeta>;
340
+ }
341
+
342
+ /**
343
+ * Filter out browser automation MCP servers.
344
+ * Since we have a native browser tool, we don't need these MCP servers.
345
+ */
346
+ export function filterBrowserMCPServers(
347
+ configs: Record<string, MCPServerConfig>,
348
+ sources: Record<string, SourceMeta>,
349
+ ): BrowserFilterResult {
350
+ const filtered: Record<string, MCPServerConfig> = {};
351
+ const filteredSources: Record<string, SourceMeta> = {};
352
+
353
+ for (const [name, config] of Object.entries(configs)) {
354
+ if (!isBrowserMCPServer(name, config)) {
355
+ filtered[name] = config;
356
+ if (sources[name]) {
357
+ filteredSources[name] = sources[name];
358
+ }
359
+ }
360
+ }
361
+
362
+ return { configs: filtered, sources: filteredSources };
363
+ }
package/src/mcp/index.ts CHANGED
@@ -8,10 +8,12 @@
8
8
  // Client
9
9
  export { callTool, connectToServer, disconnectServer, listTools, serverSupportsTools } from "./client";
10
10
  // Config
11
- export type { ExaFilterResult, LoadMCPConfigsOptions, LoadMCPConfigsResult } from "./config";
11
+ export type { BrowserFilterResult, ExaFilterResult, LoadMCPConfigsOptions, LoadMCPConfigsResult } from "./config";
12
12
  export {
13
13
  extractExaApiKey,
14
+ filterBrowserMCPServers,
14
15
  filterExaMCPServers,
16
+ isBrowserMCPServer,
15
17
  isExaMCPServer,
16
18
  loadAllMCPConfigs,
17
19
  validateServerConfig,
package/src/mcp/loader.ts CHANGED
@@ -32,6 +32,8 @@ export interface MCPToolsLoadOptions {
32
32
  enableProjectConfig?: boolean;
33
33
  /** Whether to filter out Exa MCP servers (default: true) */
34
34
  filterExa?: boolean;
35
+ /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */
36
+ filterBrowser?: boolean;
35
37
  /** SQLite storage for MCP tool cache (null disables cache) */
36
38
  cacheStorage?: AgentStorage | null;
37
39
  /** Auth storage used to resolve OAuth credentials before initial MCP connect */
@@ -69,6 +71,7 @@ export async function discoverAndLoadMCPTools(cwd: string, options?: MCPToolsLoa
69
71
  onConnecting: options?.onConnecting,
70
72
  enableProjectConfig: options?.enableProjectConfig,
71
73
  filterExa: options?.filterExa,
74
+ filterBrowser: options?.filterBrowser,
72
75
  });
73
76
  } catch (error) {
74
77
  // If discovery fails entirely, return empty result
@@ -68,6 +68,8 @@ export interface MCPDiscoverOptions {
68
68
  enableProjectConfig?: boolean;
69
69
  /** Whether to filter out Exa MCP servers (default: true) */
70
70
  filterExa?: boolean;
71
+ /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */
72
+ filterBrowser?: boolean;
71
73
  /** Called when starting to connect to servers */
72
74
  onConnecting?: (serverNames: string[]) => void;
73
75
  }
@@ -105,6 +107,7 @@ export class MCPManager {
105
107
  const { configs, exaApiKeys, sources } = await loadAllMCPConfigs(this.cwd, {
106
108
  enableProjectConfig: options?.enableProjectConfig,
107
109
  filterExa: options?.filterExa,
110
+ filterBrowser: options?.filterBrowser,
108
111
  });
109
112
  const result = await this.connectServers(configs, sources, options?.onConnecting);
110
113
  result.exaApiKeys = exaApiKeys;
@@ -198,7 +198,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
198
198
  this.mcpServerName = connection.name;
199
199
  }
200
200
 
201
- renderCall(args: unknown, theme: Theme) {
201
+ renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
202
202
  return renderMCPCall((args ?? {}) as Record<string, unknown>, theme, this.label);
203
203
  }
204
204
 
@@ -304,7 +304,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
304
304
  this.#fallbackProviderName = source?.providerName;
305
305
  }
306
306
 
307
- renderCall(args: unknown, theme: Theme) {
307
+ renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
308
308
  return renderMCPCall((args ?? {}) as Record<string, unknown>, theme, this.label);
309
309
  }
310
310
 
@@ -391,7 +391,7 @@ export class ToolExecutionComponent extends Container {
391
391
  const shouldRenderCall = !this.#result || !mergeCallAndResult;
392
392
  if (shouldRenderCall && tool.renderCall) {
393
393
  try {
394
- const callComponent = tool.renderCall(this.#getCallArgsForRender(), theme);
394
+ const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
395
395
  if (callComponent) {
396
396
  this.#contentBox.addChild(ensureInvalidate(callComponent));
397
397
  }
@@ -453,7 +453,7 @@ export class ToolExecutionComponent extends Container {
453
453
  if (shouldRenderCall) {
454
454
  // Render call component
455
455
  try {
456
- const callComponent = renderer.renderCall(this.#getCallArgsForRender(), theme, this.#renderState);
456
+ const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
457
457
  if (callComponent) {
458
458
  this.#contentBox.addChild(ensureInvalidate(callComponent));
459
459
  }
@@ -19,7 +19,6 @@ import {
19
19
  ToolUIKit,
20
20
  truncateDiffByHunk,
21
21
  } from "../tools/render-utils";
22
- import type { RenderCallOptions } from "../tools/renderers";
23
22
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
24
23
  import type { DiffError, DiffResult, Operation } from "./types";
25
24
 
@@ -254,7 +253,7 @@ function renderDiffSection(
254
253
  export const editToolRenderer = {
255
254
  mergeCallAndResult: true,
256
255
 
257
- renderCall(args: EditRenderArgs, uiTheme: Theme, options?: RenderCallOptions): Component {
256
+ renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
258
257
  const ui = new ToolUIKit(uiTheme);
259
258
  const rawPath = args.file_path || args.path || "";
260
259
  const filePath = shortenPath(rawPath);
package/src/sdk.ts CHANGED
@@ -806,6 +806,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
806
806
  enableProjectConfig: settings.get("mcp.enableProjectConfig") ?? true,
807
807
  // Always filter Exa - we have native integration
808
808
  filterExa: true,
809
+ // Filter browser MCP servers when builtin browser tool is active
810
+ filterBrowser: (settings.get("browser.enabled") as boolean) ?? false,
809
811
  cacheStorage: settings.getStorage(),
810
812
  authStorage,
811
813
  });