import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { exec } from 'node:child_process'; import { homedir, platform } from 'node:os'; import { join } from 'node:path'; import type { McpConfig, ServerEntry } from './_types.ts'; async function execOpen(pi: ExtensionAPI, target: string, browser?: string, signal?: AbortSignal) { const os = platform(); if (os === 'darwin') { return browser ? pi.exec('open', ['-a', browser, target], { signal }) : pi.exec('open', [target], { signal }); } if (os === 'win32') { return browser ? pi.exec('cmd', ['/c', 'start', '', browser, target], { signal }) : pi.exec('cmd', ['/c', 'start', '', target], { signal }); } return browser ? pi.exec(browser, [target], { signal }) : pi.exec('xdg-open', [target], { signal }); } export async function openUrl(pi: ExtensionAPI, url: string, browser?: string, signal?: AbortSignal): Promise { const result = await execOpen(pi, url, browser, signal); if (result.code !== 0) { throw new Error(result.stderr || `Failed to open browser (exit code ${result.code})`); } } export async function openPath(pi: ExtensionAPI, targetPath: string): Promise { const result = await execOpen(pi, targetPath); if (result.code !== 0) { throw new Error(result.stderr || `Failed to open path (exit code ${result.code})`); } } export async function parallelLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { const results: R[] = []; let index = 0; async function worker() { while (index < items.length) { const i = index++; results[i] = await fn(items[i]); } } const workers = new Array(Math.min(limit, items.length)).fill(null).map(() => worker()); await Promise.all(workers); return results; } export function getConfigPathFromArgv(): string | undefined { const idx = process.argv.indexOf('--mcp-config'); if (idx >= 0 && idx + 1 < process.argv.length) { return process.argv[idx + 1]; } return undefined; } export function interpolateEnvVars(value: string): string { return value .replaceAll(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? '') .replaceAll(/\$env:(\w+)/g, (_, name) => process.env[name] ?? '') .replaceAll(/\{env:(\w+)\}/g, (_, name) => process.env[name] ?? ''); } function getMissingEnvVars(value: string): string[] { const missing = new Set(); for (const match of value.matchAll(/\$\{(\w+)\}|\$env:(\w+)|\{env:(\w+)\}/g)) { const name = match[1] ?? match[2] ?? match[3]; if (name && process.env[name] === undefined) { missing.add(name); } } return [...missing]; } /** * Coalesce render requests through a microtask: any number of synchronous * calls within one tick collapse into a single requestRender, so a burst of * state events produces one frame instead of N. Returns the schedule fn. */ export function createRenderCoalescer(requestRender: () => void): () => void { let scheduled = false; return () => { if (scheduled) { return; } scheduled = true; queueMicrotask(() => { scheduled = false; requestRender(); }); }; } export function toStringRecord(value: unknown): Record | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } const result: Record = {}; for (const [key, entry] of Object.entries(value)) { if (typeof entry === 'string') { result[key] = entry; } } return Object.keys(result).length > 0 ? result : undefined; } function interpolateSecretExpression(value: string): string { if (value.startsWith('!!')) { return interpolateEnvVars(value.slice(1)); } return value.startsWith('!') ? value : interpolateEnvVars(value); } export function interpolateEnvRecord(values: Record | undefined): Record | undefined { if (!values) { return undefined; } return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, interpolateSecretExpression(value)])); } const COMMAND_SECRET_TIMEOUT_MS = 10_000; const COMMAND_SECRET_MAX_OUTPUT_BYTES = 1024 * 1024; const COMMAND_SECRET_CONCURRENCY = 4; // bounded fan-out; never input-driven interface SecretMetrics { count: number; totalMs: number; lastMs: number; } const secretMetrics: SecretMetrics = { count: 0, totalMs: 0, lastMs: 0 }; /** Read-only snapshot of command-secret resolution times (PERF-02 metric). */ export function getSecretMetrics(): SecretMetrics { return { ...secretMetrics }; } /** * Run a secret command without a shell injection hazard from the marker; * resolves stdout (untrimmed). Async so a hung command never freezes the * event loop; timeout kills the child and maps to the same error shapes the * old spawnSync path produced. */ function execCommandSecret(command: string): Promise { return new Promise((resolve, reject) => { const started = performance.now(); exec( command, { encoding: 'utf8', timeout: COMMAND_SECRET_TIMEOUT_MS, maxBuffer: COMMAND_SECRET_MAX_OUTPUT_BYTES, windowsHide: true, }, (error, stdout) => { secretMetrics.count++; secretMetrics.totalMs += performance.now() - started; secretMetrics.lastMs = performance.now() - started; if (error) { const code = (error as NodeJS.ErrnoException).code; const killedByTimeout = error.killed && (error.signal === 'SIGTERM' || error.signal === 'SIGKILL'); const reason = killedByTimeout || code === 'ETIMEDOUT' ? 'command timed out after 10 seconds' : code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || code === 'ENOBUFS' ? 'command output exceeded 1 MiB' : typeof code === 'number' ? `command exited with code ${code}` : 'command failed to start'; reject(new Error(reason)); return; } resolve(String(stdout)); } ); }); } /** * Async resolve of a secret value, executing only a single leading `!` * command marker. Never blocks the event loop; matches the previous * spawnSync error contract (timeout, output cap, nonzero exit, empty output). */ export async function resolveCommandSecret(value: string | undefined, context: string): Promise { if (value === undefined) { return undefined; } if (value.startsWith('!!')) { return interpolateEnvVars(value.slice(1)); } if (!value.startsWith('!')) { return interpolateEnvVars(value); } try { const stdout = await execCommandSecret(value.slice(1)); const resolved = stdout.trim(); if (!resolved) { throw new Error('command returned empty output'); } return resolved; } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Failed to resolve ${context}: ${reason}`); } } /** Resolve command markers in a configured record without mutating the input. */ /** * Resolve every entry in a secret record. Async; command entries fan out at * most COMMAND_SECRET_CONCURRENCY at a time and preserve input order. * Returns undefined only when the whole record is undefined. */ export async function resolveCommandSecretsRecord( values: Record | undefined, context: (key: string) => string ): Promise | undefined> { if (!values) { return undefined; } const entries = Object.entries(values); const resolvedEntries = await parallelLimit(entries, COMMAND_SECRET_CONCURRENCY, async ([key, value]) => { const next = await resolveCommandSecret(value, context(key)); return [key, next] as const; }); const resolved: Record = {}; for (const [key, next] of resolvedEntries) { if (next !== undefined) { resolved[key] = next; } } return resolved; } export function resolveServerUrl(definition: Pick): string | undefined { if (definition.url == null) { return undefined; } if (typeof definition.url !== 'string') { throw new Error('MCP server URL must be a string'); } const missing = getMissingEnvVars(definition.url); if (missing.length > 0) { throw new Error( `Missing environment variable${missing.length === 1 ? '' : 's'} in MCP server URL: ${missing.join(', ')}` ); } const resolved = interpolateEnvVars(definition.url); try { new URL(resolved); } catch (error) { throw new Error(`Invalid MCP server URL after environment interpolation: ${resolved}`, { cause: error }); } return resolved; } export function resolveConfigPath(value: string | undefined): string | undefined { if (value === undefined) { return undefined; } const resolved = interpolateEnvVars(value); if (resolved === '~') { return homedir(); } if (resolved.startsWith('~/') || resolved.startsWith('~\\')) { return join(homedir(), resolved.slice(2)); } return resolved; } export function resolveBearerToken( definition: Pick ): string | undefined { if (definition.bearerToken !== undefined) { return interpolateSecretExpression(definition.bearerToken); } return definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined; } /** Remove OSC control strings, including payloads that have no terminator. */ export function stripOscSequences(text: string): string { let result = ''; let index = 0; while (index < text.length) { const isEscOsc = text.charCodeAt(index) === 0x1b && text[index + 1] === ']'; const isC1Osc = text.charCodeAt(index) === 0x9d; if (!isEscOsc && !isC1Osc) { result += text[index++]; continue; } index += isEscOsc ? 2 : 1; while (index < text.length) { const code = text.charCodeAt(index++); if (code === 0x07 || code === 0x9c) { break; } if (code === 0x1b && text[index] === '\\') { index++; break; } } } return result; } export function sanitizeTerminalText(text: string): string { return stripOscSequences(text) .replaceAll(/(?:\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-Z\\-_])/g, '') .replaceAll(/[\u0000-\u001f\u007f-\u009f]+/g, ' ') .replaceAll(/\s+/g, ' ') .trim(); } export function formatTerminalError(error: unknown): string { const messages: string[] = []; const seen = new Set(); const collect = (value: unknown) => { if (seen.has(value)) { return; } if ((typeof value === 'object' && value !== null) || typeof value === 'function') { seen.add(value); } if (value instanceof AggregateError) { const countBefore = messages.length; for (const nested of value.errors) { collect(nested); } if (value.cause !== undefined) { collect(value.cause); } if (messages.length === countBefore && value.message) { messages.push(value.message); } return; } if (value instanceof Error) { if (value.message) { messages.push(value.message); } if (value.cause !== undefined) { collect(value.cause); } return; } messages.push(String(value)); }; collect(error); return sanitizeTerminalText([...new Set(messages)].join(': ')); } export function truncateAtWord(text: string, target: number): string { if (!text || text.length <= target) { return text; } const truncated = text.slice(0, target); const lastSpace = truncated.lastIndexOf(' '); if (lastSpace > target * 0.6) { return `${truncated.slice(0, lastSpace)}...`; } return `${truncated}...`; } export function normalizeDirectToolInputSchema(schema: unknown): Record { const inputSchema = schema && typeof schema === 'object' && !Array.isArray(schema) ? (schema as Record) : { type: 'object', properties: {} }; const { $schema, additionalProperties, ...normalized } = inputSchema; return normalized; } export function formatAuthRequiredMessage( config: Pick, serverName: string, defaultMessage: string ): string { const template = config.settings?.authRequiredMessage; return template ? template.replaceAll('${server}', serverName) : defaultMessage; } export function formatMcpStatus(config: Pick, message: string): string | undefined { if (config.settings?.mcpFooterStatus === 'off') { return undefined; } return `${config.settings?.showStatusIcon === false ? 'MCP: ' : '🔌 MCP: '}${message}`; } /** * Extract the adapter-owned UI stream mode from tool metadata. */ export function extractToolUiStreamMode( toolMeta: Record | undefined ): 'eager' | 'stream-first' | undefined { const uiMeta = toolMeta?.ui; if (!uiMeta || typeof uiMeta !== 'object') { return undefined; } const streamMode = (uiMeta as Record)['pi-mcp-adapter.streamMode']; if (streamMode === 'eager' || streamMode === 'stream-first') { return streamMode; } return undefined; }