import { tool } from '@opencode-ai/plugin/tool'; type HostPayload = Record; function hostApiBaseUrl(): string { const value = process.env.NIANCODE_HOST_API_BASE_URL?.trim(); if (!value) throw new Error('Makelore 开发浏览器服务尚未启动。'); return value.replace(/\/+$/, ''); } function hostApiToken(): string { const value = process.env.NIANCODE_HOST_API_TOKEN?.trim(); if (!value) throw new Error('Makelore 开发浏览器凭证不可用,请重启客户端后再试。'); return value; } function browserError(payload: HostPayload, status: number): string { const code = typeof payload.code === 'string' ? payload.code : ''; const message = typeof payload.message === 'string' ? payload.message : typeof payload.error === 'string' ? payload.error : ''; const fallback: Record = { PROJECT_NOT_ACTIVE: '请先在 Makelore 中打开要调试的项目。', PROJECT_MISMATCH: '智能体只能调试当前打开的项目。', BROWSER_NOT_OPEN: '开发浏览器尚未打开,请先调用 browser_context 的 open。', VIEWPORT_NOT_READY: '开发浏览器显示区域尚未准备好,请稍后重试。', DEBUGGER_BUSY: '开发浏览器调试器正忙,请稍后重试。', ATTACH_FAILED: '无法连接开发浏览器调试器,请关闭浏览器面板后重新打开。', DEVTOOLS_CONFLICT: '当前页面的原生开发者工具已打开;关闭它后智能体才能继续调试。', CDP_METHOD_BLOCKED: '该 CDP 命令会影响 Makelore 客户端或其他页面,已被阻止。', TARGET_DENIED: '不能访问当前项目浏览器之外的页面目标。', CDP_TIMEOUT: 'CDP 命令执行超时且结果未知;开发浏览器已关闭,请重新打开页面并核对状态。', CURSOR_EXPIRED: '部分较早的浏览器事件已被清理,请从返回的新游标继续读取。', PAYLOAD_NOT_FOUND: '浏览器数据已过期,请重新执行产生该数据的命令。', TARGET_GONE: '页面目标已经关闭或刷新,请重新获取页面状态。', RENDERER_CRASHED: '开发浏览器页面已崩溃,请关闭后重新打开。', }; if (code && fallback[code]) return fallback[code]; if (message) return message; if (status === 401) return '开发浏览器凭证已失效,请重启 Makelore 后再试。'; return `开发浏览器操作失败(HTTP ${status})。`; } async function hostRequest( path: string, options: { method?: 'GET' | 'POST'; body?: HostPayload }, signal: AbortSignal, ): Promise { let response: Response; try { response = await fetch(`${hostApiBaseUrl()}${path}`, { method: options.method ?? 'POST', headers: { Authorization: `Bearer ${hostApiToken()}`, Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), }, ...(options.body ? { body: JSON.stringify(options.body) } : {}), signal, }); } catch (error) { if (signal.aborted) throw new Error('开发浏览器操作已取消。', { cause: error }); const detail = error instanceof Error ? error.message : String(error); throw new Error(`无法连接 Makelore 开发浏览器服务:${detail}`, { cause: error }); } const payload = await response.json().catch(() => ({})) as HostPayload; if (!response.ok || payload.success !== true) { throw new Error(browserError(payload, response.status)); } return payload; } function result(title: string, payload: unknown) { return { title, output: JSON.stringify(payload, null, 2), metadata: { source: 'niancode-agent-browser' }, }; } export const NianCodeAgentBrowserPlugin = async () => ({ tool: { browser_context: tool({ description: 'Open, inspect, close, or reset the shared Makelore development browser for the current project. The browser is visible to the user and shares its page state with the agent. Use open with a local or public development URL before other browser tools.', args: { action: tool.schema.enum(['open', 'status', 'close', 'reset_profile']) .describe('Browser lifecycle action'), url: tool.schema.string().optional() .describe('URL to open. Required only for action=open.'), }, async execute(args, context) { const projectPath = context.directory; if (args.action === 'status') { const query = new URLSearchParams({ project_path: projectPath }); const payload = await hostRequest( `/api/agent-browser/state?${query.toString()}`, { method: 'GET' }, context.abort, ); return result('开发浏览器状态', payload.browser); } if (args.action === 'open') { const url = args.url?.trim(); if (!url) throw new Error('打开开发浏览器时必须提供 url。'); const payload = await hostRequest('/api/agent-browser/open', { body: { project_path: projectPath, url }, }, context.abort); return result('已打开开发浏览器', payload.browser); } const path = args.action === 'close' ? '/api/agent-browser/close' : '/api/agent-browser/reset-profile'; const payload = await hostRequest(path, { body: { project_path: projectPath }, }, context.abort); return result( args.action === 'close' ? '已关闭开发浏览器' : '已清空开发浏览器数据', payload.browser, ); }, }), browser_navigate: tool({ description: 'Navigate the shared development browser for the current project. Supports opening a URL, back, forward, and reload.', args: { action: tool.schema.enum(['url', 'back', 'forward', 'reload']) .describe('Navigation action'), url: tool.schema.string().optional() .describe('Destination URL. Required only for action=url.'), }, async execute(args, context) { const url = args.url?.trim(); if (args.action === 'url' && !url) { throw new Error('跳转网页时必须提供 url。'); } const payload = await hostRequest('/api/agent-browser/navigate', { body: { project_path: context.directory, action: args.action, ...(url ? { url } : {}), }, }, context.abort); return result('开发浏览器已导航', payload.browser); }, }), browser_cdp: tool({ description: 'Use Full Chrome DevTools Protocol on the current project browser. send forwards one CDP method and params without summarizing the result; read_events returns raw buffered CDP events after a cursor; read_payload reads a chunk referenced by a previous result.', args: { action: tool.schema.enum(['send', 'read_events', 'read_payload']) .describe('CDP operation'), method: tool.schema.string().optional() .describe('Full CDP method name, required for send (for example Runtime.evaluate)'), params: tool.schema.object({}).passthrough().optional() .describe('Raw CDP params for send'), session_ref: tool.schema.string().optional() .describe('Child-target session reference returned by this browser'), timeout_ms: tool.schema.number().int().positive().optional() .describe('Optional CDP command timeout'), after: tool.schema.number().int().nonnegative().optional() .describe('Read events with sequence greater than this cursor'), methods: tool.schema.array(tool.schema.string()).optional() .describe('Optional exact CDP event method filters'), limit: tool.schema.number().int().positive().optional() .describe('Maximum events to return'), wait_ms: tool.schema.number().int().nonnegative().optional() .describe('Optional event long-poll duration'), handle: tool.schema.string().optional() .describe('Payload handle, required for read_payload'), offset: tool.schema.number().int().nonnegative().optional() .describe('Payload byte offset'), max_bytes: tool.schema.number().int().positive().optional() .describe('Maximum payload bytes in this chunk'), }, async execute(args, context) { if (args.action === 'send') { const method = args.method?.trim(); if (!method) throw new Error('发送 CDP 命令时必须提供 method。'); const payload = await hostRequest('/api/agent-browser/cdp/send', { body: { project_path: context.directory, method, ...(args.params ? { params: args.params } : {}), ...(args.session_ref ? { session_ref: args.session_ref } : {}), ...(args.timeout_ms !== undefined ? { timeout_ms: args.timeout_ms } : {}), }, }, context.abort); return result(`CDP ${method}`, payload.result); } if (args.action === 'read_events') { const payload = await hostRequest('/api/agent-browser/cdp/events', { body: { project_path: context.directory, ...(args.after !== undefined ? { after: args.after } : {}), ...(args.methods ? { methods: args.methods } : {}), ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.wait_ms !== undefined ? { wait_ms: args.wait_ms } : {}), }, }, context.abort); return result('CDP 事件', payload.page); } const handle = args.handle?.trim(); if (!handle) throw new Error('读取大型 CDP 数据时必须提供 handle。'); const payload = await hostRequest('/api/agent-browser/payload/read', { body: { project_path: context.directory, handle, ...(args.offset !== undefined ? { offset: args.offset } : {}), ...(args.max_bytes !== undefined ? { max_bytes: args.max_bytes } : {}), }, }, context.abort); return result('CDP 数据分块', payload.chunk); }, }), }, });