// Codex 适配器:codex app-server(JSON-RPC)→ 统一事件(docs/design/03-providers.md §3,ADR-0003) import { createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import type { AppSettings, ProviderQuotaDto, ProviderStatusDto, QuotaWindowDto, UsagePayload } from '@agent-server/shared' import { communitySkillsDir, customSkillsDir } from '../../config' import { checkAccess } from '../../services/access-policy' import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types' import { bundledCodexPath, codexHome } from './cli' import { GATEWAY_SERVER, gatewayToolTimeoutSec } from '../../mcp-gateway/server' import { buildThreadConfig } from './permissions' import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills' import type { AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountRateLimitsResponse, GetAccountResponse, RateLimitSnapshot, ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, SkillMetadata, SkillsListResponse, McpServerElicitationRequestParams, PermissionsRequestApprovalParams, ThreadForkResponse, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification, ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse, } from './protocol/v2' import { AppServerClient, RpcRequestError } from './rpc' const MAX_TOOL_OUTPUT = 64 * 1024 const CLIENT_INFO = { name: 'agent_server', title: 'AgentServer', version: '0.1.0' } interface Logger { info(o: object, m?: string): void; warn(o: object, m?: string): void; error(o: object, m?: string): void } export interface CodexLoginState { loginId: string verificationUrl: string userCode: string status: 'pending' | 'success' | 'failed' | 'cancelled' error: string | null } export class CodexProvider implements AgentProvider { readonly id = 'codex' as const private client: AppServerClient | null = null private starting: Promise | null = null private clientKey = '' private runs = new Map() private quotaCache: { at: number; value: ProviderQuotaDto } | null = null /** * 当前 app-server 进程中已加载的会话及其加载时的配置指纹。已加载会话的配置不会被 thread/resume 更新 * (已验证,codex 0.164),配置变化时改为 thread/fork:保留历史、采用新配置(ADR-0020) */ private loaded = new Map() private models: string[] = [] private login: CodexLoginState | null = null private version: string | null = null constructor(private log: Logger) {} knownModels(): string[] { return this.models } private bin(settings: AppSettings) { return settings.providers.codex.cliPath } /** 启动(或复用)app-server;CLI 路径或配置覆盖变化时重启 */ private async ensure(settings: AppSettings): Promise { const s = settings.providers.codex const key = JSON.stringify([s.cliPath, s.configOverrides]) if (this.client?.alive && this.clientKey === key) return this.client if (this.starting && this.clientKey === key) return this.starting this.client?.stop() this.clientKey = key this.starting = (async () => { const home = codexHome() fs.mkdirSync(home, { recursive: true, mode: 0o700 }) const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: home } delete env.OPENAI_API_KEY delete env.CODEX_API_KEY const client = new AppServerClient({ bin: s.cliPath, env, args: s.configOverrides.flatMap(o => ['-c', o]) }) client.on('notification', (method, params) => this.onNotification(method, params)) client.onServerRequest = (method, params) => this.onServerRequest(method, params) client.on('stderr', line => { if (!/WARN|INFO|DEBUG/.test(line)) this.log.warn({ stderr: line.trim().slice(0, 500) }, 'codex stderr') }) client.on('exit', code => { this.log.warn({ code }, 'codex app-server 已退出') this.loaded.clear() if (this.client === client) this.client = null for (const run of this.runs.values()) run.fail(new ProviderRunError('codex_exited', `codex app-server 意外退出(${code})`)) }) const init = await client.start(CLIENT_INFO) as { userAgent?: string } // 本地技能目录(自写技能)与第三方技能目录与 Claude 共用,作为额外技能根目录 // 自定义技能目录在服务启动时创建,之后发布的技能 Codex 每次列技能时都会看到 const extraRoots = [path.join(settings.paths.skillsDir, 'skills'), path.join(communitySkillsDir(), 'skills'), path.join(customSkillsDir(), 'skills')].filter(d => fs.existsSync(d)) if (extraRoots.length) await client.request('skills/extraRoots/set', { extraRoots }).catch(err => this.log.warn({ err: (err as Error).message }, 'codex extraRoots 设置失败')) // userAgent 形如 "agent_server/0.164.0 (...)",斜杠后是 codex 版本 this.version = /\/([\d.]+)/.exec(init.userAgent ?? '')?.[1] ?? null this.client = client return client })() try { return await this.starting } finally { this.starting = null } } async run(ctx: RunContext): Promise { const client = await this.ensure(ctx.settings) fs.mkdirSync(path.join(ctx.workspaceDir, '.agent', 'tmp'), { recursive: true }) const config = buildThreadConfig({ access: ctx.access, permissionMode: ctx.permissionMode, settings: ctx.settings, codexBin: this.bin(ctx.settings), codexHome: codexHome(), allowPlugins: ctx.codexPlugins, }) // 按 Profile 的技能选择禁用其余技能(只作用于本次对话) const skills = await this.listSkills(ctx.settings, ctx.workspaceDir).catch(() => []) const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings), ctx.codexPlugins, ctx.skillsExcludedFromAll) if (disabled.length) config.skills = { config: disabled } // 与 Claude 共用的 MCP Gateway;令牌每个 Run 不同,thread/start 与 thread/resume 都会重新传入 config.mcp_servers = { [GATEWAY_SERVER]: { url: ctx.gateway.url, http_headers: { Authorization: `Bearer ${ctx.gateway.token}` }, // 安全校验在网关中完成,不再逐次询问 default_tools_approval_mode: 'approve', startup_timeout_sec: 20, // 第三方 MCP 工具可能先等待审批(交互超时)再执行(网关最多 10 分钟) tool_timeout_sec: gatewayToolTimeoutSec(ctx.settings), }, } // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败 const approvalPolicy: AskForApproval = ctx.permissionMode === 'ask' ? 'on-request' : 'never' const threadParams = { cwd: ctx.workspaceDir, model: ctx.model, approvalPolicy, config, developerInstructions: ctx.systemPromptAppend, } // 模型每轮通过 turn/start 传入,不计入指纹 const fingerprint = createHash('sha256').update(JSON.stringify({ ...threadParams, model: undefined })).digest('hex') let threadId = ctx.providerSessionId if (threadId && this.loaded.has(threadId) && this.loaded.get(threadId) !== fingerprint) { // 配置变了(Profile 的访问策略、权限模式、技能、全局禁止项、提示词等):分叉出新会话以采用新配置 try { const from = threadId const r = await client.request('thread/fork', { ...threadParams, threadId: from, excludeTurns: true }) ctx.log.info({ from, to: r.thread.id }, 'codex 会话配置已变化,分叉为新会话') void client.request('thread/unsubscribe', { threadId: from }).catch(() => {}) this.loaded.delete(from) threadId = r.thread.id ctx.setProviderSessionId(threadId) } catch (err) { // 分叉失败时不能继续使用旧配置,改为新会话(丢失上下文,但配置正确) ctx.log.warn({ err: (err as Error).message, threadId }, 'codex 会话分叉失败,以新会话继续') threadId = null } } else if (threadId && !this.loaded.has(threadId)) { try { await client.request('thread/resume', { ...threadParams, threadId, excludeTurns: true }) } catch (err) { ctx.log.warn({ err: (err as Error).message, threadId }, 'codex 会话恢复失败,以新会话继续') threadId = null } } if (!threadId) { const r = await client.request('thread/start', { ...threadParams, serviceName: 'agent-server' }) threadId = r.thread.id ctx.setProviderSessionId(threadId) } this.loaded.set(threadId, fingerprint) const run = new ThreadRun(ctx, threadId) this.runs.set(threadId, run) const onAbort = () => { if (run.turnId) void client.request('turn/interrupt', { threadId, turnId: run.turnId }).catch(() => {}) run.fail(new ProviderRunError('interrupted', '已中断')) } ctx.signal.addEventListener('abort', onAbort, { once: true }) try { const r = await client.request('turn/start', { threadId, model: ctx.model, input: buildInput(ctx) }) run.turnId = r.turn.id return await run.done } finally { ctx.signal.removeEventListener('abort', onAbort) this.runs.delete(threadId) } } private onNotification(method: string, params: unknown) { if (method === 'account/login/completed') { const p = params as { loginId: string | null; success: boolean; error: string | null } if (this.login && (!p.loginId || p.loginId === this.login.loginId)) { Object.assign(this.login, { status: p.success ? 'success' : 'failed', error: p.error }) } return } const threadId = (params as { threadId?: string } | null)?.threadId if (threadId) this.runs.get(threadId)?.onNotification(method, params) } private async onServerRequest(method: string, params: unknown): Promise { const threadId = (params as { threadId?: string } | null)?.threadId const run = threadId ? this.runs.get(threadId) : undefined if (!run) throw new Error(`没有对应的运行:${method}`) return run.onServerRequest(method, params) } /** Codex 在某个工作目录下可见的全部技能 */ async listSkills(settings: AppSettings, cwd: string): Promise { const client = await this.ensure(settings) const r = await client.request('skills/list', { cwds: [cwd], forceReload: true }, 30_000) return r.data.flatMap(e => e.skills) } /** 后台展示用:技能及其展示 ID(不含始终禁用的技能和远程插件的技能,后者不由 Profile 选择) */ async skillInfos(settings: AppSettings): Promise<{ id: string; name: string; description: string; scope: string }[]> { const skills = await this.listSkills(settings, settings.paths.workspaceRoot) return skills.filter(s => !ALWAYS_DISABLED.has(s.name) && !s.pluginId) .map(s => ({ id: skillDisplayId(s, codexHome()), name: s.name, description: s.shortDescription ?? s.description, scope: s.scope })) } /** ChatGPT 订阅额度(account/rateLimits/read),缓存 1 分钟 */ async quota(settings: AppSettings): Promise { if (this.quotaCache && Date.now() - this.quotaCache.at < 60_000) return this.quotaCache.value const client = await this.ensure(settings) const r = await client.request('account/rateLimits/read', {}, 30_000) const snapshots = Object.values(r.rateLimitsByLimitId ?? {}).filter((s): s is RateLimitSnapshot => !!s) const list = snapshots.length ? snapshots : [r.rateLimits] const windows = list.flatMap(s => quotaWindows(s, list.length > 1)) const value: ProviderQuotaDto = { provider: 'codex', plan: list.find(s => s.planType)?.planType ?? null, windows, source: 'live', fetchedAt: new Date().toISOString(), message: r.ordinaryUsageAllowed === false ? '当前账户的额度已用完或不可用' : windows.length ? null : '账户没有返回额度信息', } this.quotaCache = { at: Date.now(), value } return value } /** 直接调用 app-server(插件目录等后台功能使用) */ async request(settings: AppSettings, method: string, params: unknown, timeoutMs = 60_000): Promise { const client = await this.ensure(settings) return client.request(method, params, timeoutMs) } // ---- 账户与登录(设备码方式:在 OpenAI 官方页面输入短码完成登录,AgentServer 不接触凭证) ---- async startLogin(settings: AppSettings): Promise { const client = await this.ensure(settings) if (this.login?.status === 'pending') await this.cancelLogin(settings).catch(() => {}) const r = await client.request('account/login/start', { type: 'chatgptDeviceCode' }) if (r.type !== 'chatgptDeviceCode') throw new Error('codex 没有返回设备码') this.login = { loginId: r.loginId, verificationUrl: r.verificationUrl, userCode: r.userCode, status: 'pending', error: null } return this.login } loginState(): CodexLoginState | null { return this.login } async cancelLogin(settings: AppSettings) { if (!this.login || this.login.status !== 'pending') return const client = await this.ensure(settings) await client.request('account/login/cancel', { loginId: this.login.loginId }).catch(() => {}) this.login.status = 'cancelled' } async logout(settings: AppSettings) { const client = await this.ensure(settings) await client.request('account/logout', {}) } async status(settings: AppSettings): Promise { const s = settings.providers.codex const base: ProviderStatusDto = { id: 'codex', enabled: s.enabled, available: false, loggedIn: null, account: null, cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null, } try { const client = await this.ensure(settings) const acc = await client.request('account/read', {}, 30_000) const loggedIn = !!acc.account let models = s.models const modelLabels: Record = {} if (loggedIn) { const list = await client.request('model/list', {}, 30_000).catch(() => null) if (list) { this.models = list.data.filter(m => !m.hidden).map(m => m.model) if (!models.length) models = this.models for (const m of list.data) if (m.displayName && m.displayName !== m.model) modelLabels[m.model] = m.displayName } } const account = acc.account?.type === 'chatgpt' ? [acc.account.email, `(${acc.account.planType})`].filter(Boolean).join(' ') : acc.account?.type ?? null return { ...base, version: this.version, loggedIn, account, models, modelLabels, defaultModel: s.defaultModel || models[0] || '', available: s.enabled && loggedIn, message: loggedIn ? (s.enabled ? null : '已登录,尚未在"设置 → 引擎"中启用') : '未登录:点击"登录"按钮,用 ChatGPT 账号完成设备码登录', } } catch (err) { return { ...base, message: `无法启动 codex app-server:${(err as Error).message}` } } } } export function defaultCodexPath() { return bundledCodexPath() } function buildInput(ctx: RunContext) { let text = ctx.input.text if (ctx.input.attachments.length) { const list = ctx.input.attachments.map(a => `- ${a.path}(${a.mime})`).join('\n') text += `\n\n\n用户上传了以下文件,位于工作区中:\n${list}\n` } const images = ctx.input.attachments .filter(a => ctx.input.images.some(i => i.name === a.name)) .map(a => ({ type: 'localImage' as const, path: path.join(ctx.workspaceDir, a.path) })) return [{ type: 'text' as const, text, text_elements: [] }, ...images] } /** 一次 Run 对应的一个 turn:把通知映射为统一事件,处理审批与提问 */ class ThreadRun { turnId: string | null = null readonly done: Promise private resolve!: (r: RunResult) => void private reject!: (e: Error) => void private settled = false private items = new Map() private denied = new Set() private usageBase: UsagePayload | null = null private usageLatest: UsagePayload | null = null /** 最后一次模型调用的上下文大小 */ private lastContext = 0 constructor(private ctx: RunContext, private threadId: string) { this.done = new Promise((res, rej) => { this.resolve = res this.reject = rej }) } fail(err: Error) { if (this.settled) return this.settled = true this.reject(err) } onNotification(method: string, params: unknown) { const emit = this.ctx.emit switch (method) { case 'item/started': { const { item } = params as ItemStartedNotification this.items.set(item.id, item) const tool = describeTool(item, this.ctx.workspaceDir) if (tool) emit('tool.started', { toolCallId: item.id, ...tool }) if (item.type === 'commandExecution') this.ctx.audit('command', item.command.slice(0, 500), { itemId: item.id }) break } case 'item/agentMessage/delta': { const p = params as { itemId: string; delta: string } emit('message.delta', { messageId: p.itemId, text: p.delta }) break } case 'item/reasoning/summaryTextDelta': { const p = params as { itemId: string; delta: string } emit('reasoning.delta', { blockId: p.itemId, text: p.delta }) break } case 'item/completed': { const { item } = params as ItemCompletedNotification this.items.set(item.id, item) this.onItemCompleted(item) break } case 'thread/tokenUsage/updated': { const { tokenUsage } = params as ThreadTokenUsageUpdatedNotification const toUsage = (b: typeof tokenUsage.total): UsagePayload => ({ inputTokens: b.inputTokens - b.cachedInputTokens, outputTokens: b.outputTokens, cacheReadTokens: b.cachedInputTokens, cacheWriteTokens: b.cacheWriteInputTokens, }) // 本次 Run 的用量 = 最新累计 −(第一次通知时的累计 − 那一次的增量) if (!this.usageBase) { const t = toUsage(tokenUsage.total), l = toUsage(tokenUsage.last) this.usageBase = { inputTokens: t.inputTokens - l.inputTokens, outputTokens: t.outputTokens - l.outputTokens, cacheReadTokens: t.cacheReadTokens - l.cacheReadTokens, cacheWriteTokens: t.cacheWriteTokens - l.cacheWriteTokens, } } this.usageLatest = toUsage(tokenUsage.total) this.lastContext = tokenUsage.last.inputTokens + tokenUsage.last.outputTokens break } case 'turn/completed': { const { turn } = params as TurnCompletedNotification if (this.settled) return if (turn.status === 'completed') { this.settled = true this.resolve({ usage: this.runUsage() }) } else if (turn.status === 'interrupted') { this.fail(new ProviderRunError('interrupted', '已中断')) } else { this.fail(new ProviderRunError('codex_failed', turn.error?.message ?? '运行失败')) } break } case 'error': { const p = params as { error: { message: string }; willRetry: boolean } this.ctx.log.warn({ error: p.error.message, willRetry: p.willRetry }, 'codex error') break } } } private runUsage(): UsagePayload | null { if (!this.usageLatest || !this.usageBase) return null const b = this.usageBase, l = this.usageLatest return { inputTokens: l.inputTokens - b.inputTokens, outputTokens: l.outputTokens - b.outputTokens, cacheReadTokens: l.cacheReadTokens - b.cacheReadTokens, cacheWriteTokens: l.cacheWriteTokens - b.cacheWriteTokens, ...(this.lastContext ? { contextTokens: this.lastContext } : {}), } } private onItemCompleted(item: ThreadItem) { const emit = this.ctx.emit const done = (status: 'ok' | 'error' | 'denied', output: string) => emit('tool.completed', { toolCallId: item.id, status: this.denied.has(item.id) ? 'denied' : status, output: output.slice(0, MAX_TOOL_OUTPUT), truncated: output.length > MAX_TOOL_OUTPUT, }) switch (item.type) { case 'agentMessage': if (item.text) emit('message.completed', { messageId: item.id, text: item.text }) break case 'reasoning': { const text = item.summary.join('\n').trim() if (text) emit('reasoning.completed', { blockId: item.id, text }) break } case 'commandExecution': done(item.status === 'declined' ? 'denied' : item.status === 'completed' && (item.exitCode ?? 0) === 0 ? 'ok' : 'error', `${item.aggregatedOutput ?? ''}${item.exitCode ? `\n(exit ${item.exitCode})` : ''}`.trim()) break case 'fileChange': done(item.status === 'declined' ? 'denied' : item.status === 'completed' ? 'ok' : 'error', item.changes.map(c => `${c.kind} ${c.path}`).join('\n')) break case 'mcpToolCall': done(item.status === 'completed' ? 'ok' : 'error', item.error ? JSON.stringify(item.error) : (item.result?.content ?? []).map(c => (c as { text?: string }).text ?? '').join('\n')) break case 'webSearch': case 'imageView': done('ok', '') break } } async onServerRequest(method: string, params: unknown): Promise { const ctx = this.ctx switch (method) { case 'item/commandExecution/requestApproval': { const p = params as CommandExecutionRequestApprovalParams // 网络访问不放开:Codex 的命令一旦联网就是无限制的(可访问内网) if (p.networkApprovalContext) { this.denied.add(p.itemId) ctx.audit('network_denied', `拒绝命令联网:${p.command ?? ''}`.slice(0, 500), { networkApprovalContext: p.networkApprovalContext }) return { decision: 'decline' } } if (ctx.permissionMode === 'readonly') return { decision: 'decline' } const command = p.command ?? (this.items.get(p.itemId) as { command?: string } | undefined)?.command ?? '' const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: commandTitle(command) || '执行命令', input: { command, cwd: p.cwd }, detail: { command, reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'], }) ctx.audit('approval', `codex 命令:${outcome.resolution}`, { command }) if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' } this.denied.add(p.itemId) return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' } } case 'item/fileChange/requestApproval': { const p = params as FileChangeRequestApprovalParams const item = this.items.get(p.itemId) const paths = item?.type === 'fileChange' ? item.changes.map(c => path.resolve(ctx.workspaceDir, c.path)) : [] if (p.grantRoot) paths.push(path.resolve(ctx.workspaceDir, p.grantRoot)) const blocked = paths.map(f => ({ f, d: checkAccess(ctx.access, f, 'write') })).find(x => !x.d.allowed) if (ctx.permissionMode === 'readonly' || blocked || !paths.length) { this.denied.add(p.itemId) if (blocked && !blocked.d.allowed) ctx.audit('file_access_denied', blocked.d.reason, { tool: 'codex fileChange', paths }) return { decision: 'decline' } } const outcome = await ctx.interact({ kind: 'approval', tool: 'Edit', title: paths.map(f => path.relative(ctx.workspaceDir, f)).join('、'), input: { paths }, detail: { path: paths[0], reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'], }) if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' } this.denied.add(p.itemId) return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' } } case 'item/permissions/requestApproval': { // 申请额外权限:联网一律拒绝;文件路径必须在 Profile 的访问策略之内,然后由人审批 const p = params as PermissionsRequestApprovalParams const reqFs = p.permissions.fileSystem const reads = (reqFs?.read ?? []).map(f => path.resolve(p.cwd, f)) const writes = (reqFs?.write ?? []).map(f => path.resolve(p.cwd, f)) const outside = [...reads.map(f => checkAccess(ctx.access, f, 'read')), ...writes.map(f => checkAccess(ctx.access, f, 'write'))].find(d => !d.allowed) const wantsNetwork = !!p.permissions.network?.enabled if (wantsNetwork) ctx.audit('network_denied', '拒绝 codex 申请联网权限', { reason: p.reason }) if (outside && !outside.allowed) ctx.audit('file_access_denied', outside.reason, { reads, writes }) if (wantsNetwork || outside || (!reads.length && !writes.length) || ctx.permissionMode !== 'ask') { return { permissions: {}, scope: 'turn' } } const outcome = await ctx.interact({ kind: 'approval', tool: 'Permissions', title: `申请访问:${[...reads, ...writes].join('、')}`.slice(0, 300), input: p.permissions, detail: { reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'], }) if (outcome.resolution !== 'allowed') return { permissions: {}, scope: 'turn' } return { permissions: { fileSystem: { read: reads, write: writes } }, scope: outcome.scope === 'session' ? 'session' : 'turn' } } case 'item/tool/requestUserInput': { const p = params as ToolRequestUserInputParams const outcome = await ctx.interact({ kind: 'question', questions: p.questions.map(q => ({ id: q.id, question: q.question, header: q.header, multiSelect: false, options: (q.options ?? []).map(o => ({ label: o.label, description: o.description })), })), }) const answers: Record = {} if (outcome.resolution === 'answered') { for (const q of p.questions) { const a = outcome.answers[q.id] if (a !== undefined) answers[q.id] = { answers: Array.isArray(a) ? a : [a] } } } return { answers } } case 'mcpServer/elicitation/request': { // 应用连接器的调用确认以 elicitation 形式到达:只在开放插件的会话(Playground)中, // 且不需要填写内容时转成审批卡片;需要填表或跳转网址的请求一律拒绝 const p = params as McpServerElicitationRequestParams const schema = 'requestedSchema' in p ? p.requestedSchema as { properties?: Record } | null : null const needsInput = p.mode === 'url' || Object.keys(schema?.properties ?? {}).length > 0 if (!ctx.codexPlugins || p.serverName === GATEWAY_SERVER || needsInput) { ctx.audit('approval', `拒绝 MCP 请求(${p.serverName},${p.mode}):${p.message}`.slice(0, 500)) return { action: 'decline', content: null, _meta: null } } const outcome = await ctx.interact({ kind: 'approval', tool: `mcp__${p.serverName}`, title: p.message.split('\n')[0]!.slice(0, 200), input: { server: p.serverName }, detail: { reason: p.message }, options: ['allow_once', 'deny'], }) ctx.audit('approval', `codex 应用调用(${p.serverName}):${outcome.resolution}`, { message: p.message }) return { action: outcome.resolution === 'allowed' ? 'accept' : 'decline', content: outcome.resolution === 'allowed' ? {} : null, _meta: null } } default: throw new RpcRequestError(method, { code: -32601, message: `AgentServer 不支持:${method}` }) } } } function quotaWindows(s: RateLimitSnapshot, named: boolean): QuotaWindowDto[] { const label = (mins: number | null) => (mins === null ? '窗口' : mins % 1440 === 0 ? `${mins / 1440} 天` : mins % 60 === 0 ? `${mins / 60} 小时` : `${mins} 分钟`) const prefix = named && (s.limitName ?? s.limitId) ? `${s.limitName ?? s.limitId} · ` : '' return ([['primary', s.primary], ['secondary', s.secondary]] as const).flatMap(([k, w]) => (w ? [{ key: `${s.limitId ?? 'default'}:${k}`, label: prefix + label(w.windowDurationMins), usedPercent: w.usedPercent, resetsAt: w.resetsAt ? new Date(w.resetsAt * 1000).toISOString() : null, }] : [])) } /** Codex 把命令包成 `/usr/bin/bash -lc "..."`,显示时去掉外层,只保留第一行 */ export function commandTitle(command: string): string { let c = command.trim() const m = /^(?:\/usr)?(?:\/bin\/)?(?:ba|z)?sh\s+-l?c\s+([\s\S]*)$/.exec(c) if (m) { c = m[1]!.trim() if ((c.startsWith('"') && c.endsWith('"')) || (c.startsWith("'") && c.endsWith("'"))) c = c.slice(1, -1) } return c.split('\n')[0]!.slice(0, 200) } function describeTool(item: ThreadItem, cwd: string): { name: string; title: string; input: unknown } | null { const rel = (p: string) => { const r = path.relative(cwd, path.resolve(cwd, p)) return r.startsWith('..') ? p : r } switch (item.type) { case 'commandExecution': return { name: 'Bash', title: commandTitle(item.command), input: { command: item.command, cwd: item.cwd } } case 'fileChange': return { name: 'Edit', title: item.changes.map(c => rel(c.path)).join('、').slice(0, 200), input: { changes: item.changes.map(c => ({ path: c.path, kind: c.kind, diff: c.diff })) } } case 'mcpToolCall': { // 标题取最有代表性的参数:网址 / 输入文件(及目标格式) const a = (item.arguments ?? {}) as Record const title = typeof a.url === 'string' ? a.url : typeof a.input === 'string' ? `${rel(a.input)}${typeof a.format === 'string' ? ` → ${a.format}` : ''}` : '' return { name: `mcp__${item.server}__${item.tool}`, title, input: item.arguments } } case 'webSearch': return { name: 'WebSearch', title: (item as { query?: string }).query ?? '', input: { query: (item as { query?: string }).query } } case 'imageView': return { name: 'Read', title: rel(item.path), input: { path: item.path } } default: return null } } function localPluginName(settings: AppSettings): string { try { return (JSON.parse(fs.readFileSync(path.join(settings.paths.skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name } catch { return 'agent-skills' } }