/** * OMP 内核 sidecar(`omp --mode rpc`)。 * * OMP RPC uses newline-delimited JSON frames over stdio: * - stdio 上的 **newline-delimited JSONL**(不是 JSON-RPC 2.0) * - 启动后先写一个 `ready` 帧 * - stdin:命令(可带 `id` 用于关联);stdout:响应 + 会话事件 * - 单帧上限 1 MiB * * 持久会话使用 OMP 自己的 JSONL transcript;未提供 sessionPath 时保持临时模式。 */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { createInterface } from 'node:readline' import { EventEmitter } from 'node:events' import { existsSync, readFileSync } from 'node:fs' import { mkdir } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' /** OMP 单帧上限(含换行) */ const MAX_FRAME_BYTES = 1024 * 1024 /** 命令默认超时 */ const COMMAND_TIMEOUT_MS = 60_000 export interface OmpFrame { type: string id?: string command?: string success?: boolean data?: TData error?: string | { message?: string } [key: string]: unknown } export interface OmpAgentMessage { role: string content?: unknown[] [key: string]: unknown } export interface OmpSessionTreeEntry { id: string parentId: string | null type: string message?: OmpAgentMessage [key: string]: unknown } export interface OmpSessionTreeNode { entry: OmpSessionTreeEntry children: OmpSessionTreeNode[] label?: string } export interface OmpBranchMessage { entryId: string text: string } export interface OmpHostToolDefinition { name: string label?: string description: string parameters: Record } export interface OmpToolInfo { id?: string name?: string /** Optional human-readable action text emitted alongside the native tool call. */ intent?: string argsRaw?: string /** 部分输出 / 最终输出(尽力解析) */ output?: string ok?: boolean isError?: boolean } export interface OmpPromptImage { type: 'image' data: string mimeType: string } interface Pending { resolve: (value: OmpFrame) => void reject: (err: Error) => void timer: NodeJS.Timeout } /** * 随包内置的 OMP:打包后位于 resources/omp,开发态位于项目 resources/omp。 * 版本与摘要由 resources/omp/runtime.lock.json 锁定(npm run omp:verify 校验)。 */ function detectBundledOmpPath(): string | null { let moduleBundled: string | null = null if (typeof __dirname === 'string' && __dirname) { moduleBundled = resolve(__dirname, '..', '..', 'resources', 'omp', 'omp.exe') } else if (typeof import.meta.url === 'string') { try { moduleBundled = fileURLToPath(new URL('../../resources/omp/omp.exe', import.meta.url)) } catch { // CJS test bundles may expose an empty import.meta object. } } const candidates = [ process.resourcesPath ? join(process.resourcesPath, 'omp', 'omp.exe') : null, join(process.cwd(), 'resources', 'omp', 'omp.exe'), moduleBundled ] for (const candidate of candidates) { if (candidate && existsSync(candidate)) return candidate } return null } /** 探测 OMP 可执行文件:显式覆盖 > 随包内置 > 本机 Ygent 安装 */ export function detectOmpPath(): string | null { const candidates = [ process.env['YCODE_OMP_PATH'], detectBundledOmpPath(), process.env['LOCALAPPDATA'] ? `${process.env['LOCALAPPDATA']}\\Ygent\\omp.exe` : null, 'C:\\Program Files\\Ygent\\omp.exe' ].filter((p): p is string => !!p) for (const p of candidates) { if (existsSync(p)) return p } return null } /** OMP 内核的对外信息(设置页 / 输入框展示用) */ export interface OmpInfo { available: boolean exePath: string | null profile: string /** 从 profile 的 models.yml 读出的首个自定义模型(`/` 形式),读不到为 null */ model: string | null } /** Returns the first custom model with its OMP provider namespace. */ export function parseFirstProfileModel(modelsYaml: string): string | null { let inProviders = false let providerId: string | null = null let inModels = false for (const line of modelsYaml.split(/\r?\n/)) { if (!line.trim() || line.trimStart().startsWith('#')) continue const indent = line.length - line.trimStart().length const content = line.trimStart() if (indent === 0) { inProviders = /^providers:\s*(?:#.*)?$/.test(content) providerId = null inModels = false continue } if (!inProviders) continue if (indent === 2) { const provider = content.match(/^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9._-]+)):\s*(?:#.*)?$/) providerId = provider?.[1] ?? provider?.[2] ?? provider?.[3] ?? null inModels = false continue } if (!providerId) continue if (indent === 4) { inModels = /^models:\s*(?:#.*)?$/.test(content) continue } if (!inModels || indent < 6) continue const model = content.match(/^[-]\s*id:\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))\s*(?:#.*)?$/) const modelId = model?.[1] ?? model?.[2] ?? model?.[3] if (modelId) return `${providerId}/${modelId}` } return null } /** Reads the executable and first provider/model pair declared in the selected profile. */ export function getOmpInfo(profile = 'ycode'): OmpInfo { const exePath = detectOmpPath() if (!exePath) { return { available: false, exePath: null, profile, model: null } } const modelsPath = join(homedir(), '.omp', 'profiles', profile, 'agent', 'models.yml') let model: string | null = null try { const text = readFileSync(modelsPath, 'utf8') model = parseFirstProfileModel(text) } catch { // profile 未配置时只暴露可用性 } return { available: true, exePath, profile, model } } export class OmpKernel extends EventEmitter { get isStarted(): boolean { return this.readyDone && !this.disposed } private child: ChildProcessWithoutNullStreams | null = null private pending = new Map() private seq = 0 private starting: Promise | null = null private readyDone = false private disposed = false private disposal: Promise | null = null private startupErrorReported = false constructor( private readonly exePath: string, private readonly cwd: string, private readonly profile = 'ycode', private readonly sessionPath?: string, private readonly modelOverride?: string | null, private readonly environment?: NodeJS.ProcessEnv ) { super() } /** 启动进程并等待 ready 帧;重复调用只会启动一次 */ async start(): Promise { if (this.disposed) throw new Error('OMP 内核已释放') if (this.readyDone) return if (this.starting) return this.starting this.startupErrorReported = false const starting = this.startProcess().catch((err: Error) => { if (this.starting === starting) this.starting = null if (!this.startupErrorReported && this.listenerCount('error') > 0) { this.startupErrorReported = true this.emit('error', err) } throw err }) this.starting = starting return starting } private async startProcess(): Promise { if (!existsSync(this.exePath)) { throw new Error(`OMP 可执行文件不存在:${this.exePath}`) } if (this.sessionPath) { if (!isAbsolute(this.sessionPath)) throw new Error('OMP 会话路径必须是绝对路径') await mkdir(dirname(this.sessionPath), { recursive: true }) } if (this.disposed) throw new Error('OMP 内核已释放') // spawn 的 cwd 必须存在,否则报 ENOENT(与可执行文件无关) const cwd = existsSync(this.cwd) ? this.cwd : homedir() const model = this.modelOverride === undefined ? getOmpInfo(this.profile).model : this.modelOverride if (!model || model.indexOf('/') <= 0 || model.endsWith('/')) { throw new Error(`OMP profile "${this.profile}" 缺少有效的 provider/model;请在 agent/models.yml 中配置模型`) } const args = ['--mode', 'rpc', '--profile', this.profile, `--model=${model}`, '--cwd', cwd, '--no-tools'] if (this.environment?.YCODE_MODEL_CONFIG) args.push('--config', this.environment.YCODE_MODEL_CONFIG) if (this.environment?.YCODE_CONTEXT_OBSERVER_EXTENSION) args.push('--extension',this.environment.YCODE_CONTEXT_OBSERVER_EXTENSION) if (this.sessionPath) { args.push(`--session-dir=${dirname(this.sessionPath)}`, `--resume=${this.sessionPath}`) } else { args.push('--no-session') } const child = spawn(this.exePath, args, { cwd, windowsHide: true, env: { ...process.env, ...this.environment } }) this.child = child const rl = createInterface({ input: child.stdout }) await new Promise((resolve, reject) => { let settled = false let startupTimer: NodeJS.Timeout | undefined const settle = (err?: Error): void => { if (settled) return settled = true if (startupTimer) clearTimeout(startupTimer) this.off('frame', onReady) if (err) reject(err) else resolve() } const onReady = (frame: OmpFrame): void => { if (frame.type !== 'ready') return this.readyDone = true settle() } child.on('error', (err) => { this.startupErrorReported = true if (this.listenerCount('error') > 0) this.emit('error', err) settle(err) }) child.on('exit', (code) => { const wasReady = this.readyDone this.emit('exit', code) rl.close() for (const [id, pending] of this.pending) { clearTimeout(pending.timer) pending.reject(new Error(`OMP 进程已退出(code=${code}),命令 ${id} 未完成`)) } this.pending.clear() if (this.child === child) this.child = null this.readyDone = false this.starting = null if (!wasReady) settle(new Error(`OMP 进程在 ready 前退出(code=${code})`)) }) child.stderr.on('data', (chunk: Buffer) => { this.emit('stderr', chunk.toString()) }) rl.on('line', (line) => this.handleLine(line)) this.on('frame', onReady) startupTimer = setTimeout(() => settle(new Error('OMP 启动超时(未收到 ready 帧)')), 30_000) startupTimer.unref() }) } private handleLine(line: string): void { const trimmed = line.trim() if (!trimmed) return if (trimmed.length > MAX_FRAME_BYTES) { this.emit('error', new Error(`OMP 帧超过上限(${trimmed.length} 字节)`)) return } let frame: OmpFrame try { frame = JSON.parse(trimmed) as OmpFrame } catch { this.emit('error', new Error(`OMP 输出无法解析:${trimmed.slice(0, 200)}`)) return } // 带响应 id 的帧 → 关联到挂起命令 if (frame.id && this.pending.has(frame.id)) { const p = this.pending.get(frame.id) if (p) { this.pending.delete(frame.id) clearTimeout(p.timer) p.resolve(frame) } // 响应帧同时也广播(prompt 的 ack 可能带 agentInvoked 信息) this.emit('frame', frame) return } this.emit('frame', frame) this.emit('event', frame) } /** 发送一条命令并等待其响应 */ async send(command: Record, timeoutMs = COMMAND_TIMEOUT_MS): Promise { await this.start() const id = `req_${++this.seq}` const payload = JSON.stringify({ id, ...command }) return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id) reject(new Error(`OMP 命令超时:${String(command.type)}`)) }, timeoutMs) timer.unref?.() this.pending.set(id, { resolve, reject, timer }) this.child?.stdin.write(payload + '\n') }) } /** 发起一次对话回合;事件经 `event` 回调流出 */ async prompt(message: string, images?: OmpPromptImage[]): Promise { const command: Record = { type: 'prompt', message } if (images?.length) command.images = images return this.send(command, COMMAND_TIMEOUT_MS * 10) } async abort(): Promise { return this.send({ type: 'abort' }, 15_000) } async getMessages(): Promise> { return this.send({ type: 'get_messages' }) as Promise> } async getTree(): Promise> { return this.send({ type: 'get_tree' }) as Promise> } async getBranchMessages(): Promise> { return this.send({ type: 'get_branch_messages' }) as Promise> } async branch(entryId: string): Promise> { if (!entryId.trim()) throw new Error('OMP branch requires a native user message entryId') return this.send({ type: 'branch', entryId }) as Promise> } async setHostTools(tools: OmpHostToolDefinition[]): Promise> { return this.send({ type: 'set_host_tools', tools }) as Promise> } /** Levels come from `getAvailableThinkingLevels()`; `off` disables thinking for this session. */ async setThinkingLevel(level: string): Promise { await this.send({ type: 'set_thinking_level', level }) } async getAvailableThinkingLevels(): Promise { const frame = await this.send({ type: 'get_available_thinking_levels' }) as OmpFrame<{ levels?: string[] }> return Array.isArray(frame.levels) ? frame.levels : [] } /** Resolve an OMP host_tool_call. The host request id, not toolCallId, correlates the reply. */ async respondToHostTool(requestId: string, content: string, isError = false, images?: OmpPromptImage[]): Promise { await this.start() if (!requestId.trim()) throw new Error('OMP host tool response requires a request id') const child = this.child if (!child) throw new Error('OMP sidecar is not running') const payload = { type: 'host_tool_result', id: requestId, result: { content: [{ type: 'text', text: content }, ...(!isError ? images ?? [] : [])] }, ...(isError ? { isError: true } : {}) } const frame = `${JSON.stringify(payload)}\n` if (Buffer.byteLength(frame) > MAX_FRAME_BYTES) throw new Error('OMP 工具结果超过单帧大小限制') child.stdin.write(frame) } dispose(): Promise { if (this.disposal) return this.disposal this.disposed = true const child = this.child if (!child) { this.removeAllListeners() this.disposal = Promise.resolve() return this.disposal } this.disposal = new Promise((resolve) => { let completed = false let killTimer: NodeJS.Timeout | undefined const finish = (): void => { if (completed) return completed = true if (killTimer) clearTimeout(killTimer) child.off('exit', finish) child.off('close', finish) child.off('error', finishOnSpawnError) if (this.child === child) this.child = null this.removeAllListeners() resolve() } const finishOnSpawnError = (): void => { if (child.pid === undefined) finish() } if (child.exitCode !== null || child.signalCode !== null) { finish() return } child.once('exit', finish) child.once('close', finish) child.on('error', finishOnSpawnError) try { child.stdin.end() } catch { // 进程可能已退出 } killTimer = setTimeout(() => { if (child.exitCode !== null || child.signalCode !== null) return try { child.kill() } catch { // exit / close 事件仍负责完成释放 } }, 1500) killTimer.unref() }) return this.disposal } }