* OpenCode CLI 适配器(V1/V2 自动兼容门面)
*
* OpenCode 2.x 对 Server API 做了破坏性变更(端点迁移到 /api/ 前缀、要求认证、
* 消息模型扁平化),本模块对外保持与原 V1 版 OpenCodeCLI 完全一致的接口,
* 内部按以下策略自动选择适配器:
*
* OpenCodeCLI(本文件,门面)
* ├── OpenCodeV1(v1.js,@opencode-ai/sdk) → opencode 1.x / deveco-code
* └── OpenCodeV2(v2.js,@opencode/client) → opencode 2.x
*
* 版本判定(detect.js):
* 优先级:options.apiVersion > 环境变量 OPENCODE_API_VERSION > auto 自动探测
* auto 模式:
* - 显式配置了 baseUrl/host/port(或 OPENCODE_HOST/PORT)→ 仅探测该地址
* (/api/info 401 或 JSON ⇒ V2;/session JSON ⇒ V1、HTML ⇒ V2)
* - 未显式配置 → 先做 V2 后台服务发现(动态端口 + 注册文件自动认证,
* 升级后"零配置"即用),失败再探测默认 localhost:4096
* V2 显式服务密码:options.password 或 OPENCODE_PASSWORD
* (`opencode serve` 启动时 stdout 打印 "server password ...")
*
* 自动切换:
* - 检测结果在实例内缓存,构造函数保持同步(与原版一致)
* - auto 模式下,调用失败且错误特征疑似"版本不匹配"(404/405/401、
* HTML 解析失败等)时自动失效缓存,下一次调用重新探测并切换适配器
* (典型场景:V1 服务原地升级为 V2 后长驻进程自动恢复)
* - 也可调用 redetect() 手动触发重探
*
* 依赖环境变量(可由 options 覆盖):
* OPENCODE_HOST - 服务主机(默认 localhost)
* OPENCODE_PORT - 服务端口(默认 4096;显式设置后将只探测该地址)
* OPENCODE_API_VERSION - 强制版本 'v1'|'v2'(默认 auto)
* OPENCODE_PASSWORD - V2 显式服务密码
*
* @example
* import OpenCodeCLI from './opencode/index.js';
* const cli = new OpenCodeCLI({ cwd: '/workspace' }); // 同步构造,接口不变
* await cli.checkHealth(); // 首次调用时完成版本探测
* cli.detectedVersion; // → 'v1' | 'v2' | null
*/
import MessageArchive from '../../DbUse/MessageArchive.js';
import { createLogger } from '../../Routes/library/logger.js';
import { resolveOpenCodeTarget, normalizeVersion } from './detect.js';
class OpenCodeCLI {
static _cliType = 'opencode';
static _loggerName = 'OpenCodeCLI';
* @param {object} options
* @param {string} [options.host] - 服务主机
* @param {string} [options.port] - 服务端口
* @param {string} [options.baseUrl] - 完整 baseUrl,优先级高于 host/port
* @param {string} [options.apiVersion] - 'v1'|'v2'|'auto'(默认 auto)
* @param {string} [options.password] - V2 显式服务密码
* @param {import('../../DbUse/MessageArchive.js').default} [options.archive] - 消息存档服务
*/
constructor(options = {}) {
this._options = options;
this.apiVersion = normalizeVersion(options.apiVersion || process.env.OPENCODE_API_VERSION) || 'auto';
this.host = options.host || process.env.OPENCODE_HOST || 'localhost';
this.port = options.port || process.env.OPENCODE_PORT || '4096';
this.baseUrl = options.baseUrl || `http://${this.host}:${this.port}`;
this.cliType = this.constructor._cliType;
this._loggerName = this.constructor._loggerName;
this.archive = options.archive ?? new MessageArchive();
this._impl = null;
this._detectPromise = null;
}
get detectedVersion() {
return this._impl?.apiVersion ?? null;
}
get versionPolicy() {
return this.apiVersion;
}
* 手动触发重新探测(清除已缓存的适配器,下一次调用生效)。
* 典型场景:目标服务刚完成版本升级/重启。
*/
redetect() {
this._impl = null;
this._detectPromise = null;
}
* 惰性完成版本探测并构建适配器(并发安全,失败不缓存以便重试)
* @returns {Promise<import('./base.js').default>}
*/
async _ensureImpl() {
if (this._impl) return this._impl;
if (!this._detectPromise) {
this._detectPromise = (async () => {
const target = await resolveOpenCodeTarget(this._options);
this._log('info',
`连接 OpenCode ${target.version.toUpperCase()} 服务 → ${target.baseUrl}` +
(target.detected ? '(自动探测)' : '(显式指定)'));
const Impl = target.version === 'v2'
? (await import('./v2.js')).default
: (await import('./v1.js')).default;
this._impl = new Impl({
...this._options,
baseUrl: target.baseUrl,
auth: target.auth ?? null,
password: target.auth?.password ?? this._options.password,
archive: this.archive
});
this.baseUrl = target.baseUrl;
return this._impl;
})().catch(err => {
this._detectPromise = null;
throw err;
});
}
return this._detectPromise;
}
* 委托调用内部适配器;auto 模式下失败特征疑似版本不匹配时失效缓存,
* 下一次调用自动重新探测并切换适配器。
* @template T
* @param {(impl: import('./base.js').default) => Promise<T>} fn
* @returns {Promise<T>}
*/
async _withImpl(fn) {
const impl = await this._ensureImpl();
try {
return await fn(impl);
} catch (err) {
if (this.apiVersion === 'auto' && this._looksLikeVersionMismatch(err)) {
this._log('warn',
`调用失败且特征疑似版本不匹配(${err.message}),已失效版本缓存,下次调用将重新探测`);
this.redetect();
}
throw err;
}
}
* 判断错误是否疑似"适配器版本与服务版本不匹配"
* 典型特征:V1 SDK 打到 V2 服务(404/405/401、HTML 解析失败、data.map 崩溃)
*/
_looksLikeVersionMismatch(err) {
if (err?.opencodeVersionMismatch) return true;
const msg = String(err?.message || '');
return /(\b40[145]\b)|not found|Unauthorized|Unexpected token|Unexpected '<'|\.map is not a function|reading 'map'|非 JSON/i.test(msg);
}
async checkHealth() {
try {
return await this._withImpl(impl => impl.checkHealth());
} catch (error) {
this._log('warn', `checkHealth: 连接失败 — ${error.message}`);
return {
status: 'error',
message: `Health check failed: ${error.message}`,
error: error.message
};
}
}
async listSessions() {
return this._withImpl(impl => impl.listSessions());
}
async getSession(sessionId) {
return this._withImpl(impl => impl.getSession(sessionId));
}
async deleteSession(sessionId) {
return this._withImpl(impl => impl.deleteSession(sessionId));
}
async abortSession(sessionId) {
return this._withImpl(impl => impl.abortSession(sessionId));
}
async sendMessage(sessionId, message) {
return this._withImpl(impl => impl.sendMessage(sessionId, message));
}
* 向指定 Session 发送消息并同步等待 AI 回复完成
* ⚠️ AI 回复可能耗时较长(几十秒到几分钟),调用方需设置合理的超时。
* @returns {Promise<{ success: boolean, response: object, content: string }>}
*/
async sendAndWaitMessage(sessionId, message) {
return this._withImpl(impl => impl.sendAndWaitMessage(sessionId, message));
}
* 向指定 Session 注入提示词/上下文(不触发 AI 响应)
* V1: noReply:true;V2: synthetic 消息
*/
async injectPrompt(sessionId, message) {
return this._withImpl(impl => impl.injectPrompt(sessionId, message));
}
* 创建新 Session,可选附带初始提示词
* @param {string} title - Session 标题
* @param {string} [prompt] - 初始提示词(可选)
* @param {object} [options]
* @param {string} [options.cwd] - 工作目录
*/
async startSession(title, prompt, options = {}) {
return this._withImpl(impl => impl.startSession(title, prompt, options));
}
async checkSessionStatus(sessionId) {
return this._withImpl(impl => impl.checkSessionStatus(sessionId));
}
async getSessionMessages(sessionId) {
return this._withImpl(impl => impl.getSessionMessages(sessionId));
}
async getLiveMessages(sessionId) {
return this._withImpl(impl => impl.getLiveMessages(sessionId));
}
async exportSession(sessionId, opts = {}) {
return this._withImpl(impl => impl.exportSession(sessionId, opts));
}
async getArchivedMessages(sessionId) {
return this.archive.readMessages(sessionId);
}
async getArchiveMeta(sessionId) {
return this.archive.readMeta(sessionId);
}
_log(level, msg) {
const logger = createLogger(this._loggerName);
switch (level) {
case 'error':
logger.error(`❌ ${msg}`);
break;
case 'warn':
logger.warn(`⚠️ ${msg}`);
break;
case 'debug':
logger.debug(`🔍 ${msg}`);
break;
default:
logger.info(`ℹ️ ${msg}`);
}
}
}
* 异步工厂:创建门面实例并立即完成版本探测(失败抛出)。
* 适合需要立刻确认连接目标的场景;普通场景直接 new OpenCodeCLI() 即可。
*
* @param {object} [options] - 同 OpenCodeCLI 构造参数
* @returns {Promise<OpenCodeCLI>}
*/
export async function createOpenCodeCLI(options = {}) {
const cli = new OpenCodeCLI(options);
await cli._ensureImpl();
return cli;
}
export default OpenCodeCLI;