@bolloon/bolloon-agent 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import { checkWritePath } from './shell-guard.js';
8
8
  import { p2pDocumentTools } from './p2p-document-tools.js';
9
9
  import { runSelfImproveLoop } from './pi-sdk-session-factory.js';
10
10
  import { getMinimax } from '../constraints/index.js';
11
+ import { delegateToEngine } from '../external-engines/delegate.js';
11
12
  /**
12
13
  * Tools 模块 — 从 pi-sdk.ts 抽出的 registerTools() / _registerWalletTools() / _setupInboxListener()
13
14
  * / wrapToolsWithIdempotency() (2026-07-06).
@@ -267,6 +268,37 @@ export function registerBuiltinTools(ctx) {
267
268
  return { success: true, output: `📞 RPC 任务已发送给 ${peerId.substring(0, 16)}...\n requestId=${requestId}` };
268
269
  }
269
270
  });
271
+ // delegate_to_engine — 把编码任务委派给本机已安装的其他 AI 编码智能体 CLI
272
+ // (codex / claude-code / opencode / openclaw / hermes). 它们必须已安装且可达 PATH.
273
+ // 实验 API 引擎 (experiment:xxx) 是供应商不是 CLI, 不支持委派, 工具会提示改用 import.
274
+ ctx.tools.set('delegate_to_engine', {
275
+ name: 'delegate_to_engine',
276
+ description: '把编码任务委派给本机已安装的其他 AI 编码智能体 (子智能体) 执行: codex / claude-code / opencode / openclaw / hermes. 引擎需已安装且在 PATH 上. 返回其执行输出. 注意: 各工具 CLI 的非交互参数随版本变化, 若报错请检查该工具版本对应 flag.',
277
+ parameters: {
278
+ engine: "引擎 id: codex / claude-code / opencode / openclaw / hermes (实验 API 不支持委派)",
279
+ prompt: '派发的任务描述 (作为单参数传给该引擎 CLI)',
280
+ model: '可选, 强制指定模型 (如 deepseek/deepseek-v4-flash), 需引擎支持',
281
+ cwd: '可选, 工作目录, 默认当前目录'
282
+ },
283
+ execute: async (args) => {
284
+ const engine = String(args.engine || '').trim();
285
+ const prompt = String(args.prompt || '').trim();
286
+ if (!engine)
287
+ return { success: false, error: 'engine 必填' };
288
+ if (!prompt)
289
+ return { success: false, error: 'prompt 必填' };
290
+ const cwd = args.cwd ? String(args.cwd).trim() : undefined;
291
+ const model = args.model ? String(args.model).trim() : undefined;
292
+ const result = await delegateToEngine(engine, prompt, { cwd: cwd || undefined, ...(model ? { model } : {}) });
293
+ if (!result.success) {
294
+ return { success: false, error: result.error, output: result.output };
295
+ }
296
+ return {
297
+ success: true,
298
+ output: `🤖 ${engine} 执行结果 (exitCode=${result.exitCode}):\n${result.output || '(无输出)'}`
299
+ };
300
+ }
301
+ });
270
302
  ctx.tools.set('get_identity', {
271
303
  name: 'get_identity',
272
304
  description: '获取当前智能体身份信息',
@@ -1217,12 +1249,26 @@ export function registerWalletTools(ctx) {
1217
1249
  });
1218
1250
  ctx.tools.set('polymarket_get_orders', {
1219
1251
  name: 'polymarket_get_orders',
1220
- description: '查询 Polymarket 订单.',
1221
- parameters: { marketId: '可选 按市场 ID 过滤' },
1252
+ description: '查询 Polymarket 开放订单. 需要钱包私钥 privateKey 做 API key 鉴权.',
1253
+ parameters: {
1254
+ privateKey: '钱包私钥 0x... (必填)',
1255
+ marketId: '可选 按市场 ID 过滤',
1256
+ apiKey: '可选, 已存在的 API key',
1257
+ apiSecret: '可选, API secret',
1258
+ apiPassphrase: '可选, API passphrase',
1259
+ funder: '可选, 资金地址',
1260
+ },
1222
1261
  execute: async (args) => {
1223
1262
  try {
1224
1263
  const { getOrders } = await import('../constraint-runtime/dist/tools/PolymarketSDK/getOrders.js').catch(() => import('../constraint-runtime/src/tools/PolymarketSDK/getOrders.js'));
1225
- const orders = await getOrders(args.marketId ? { marketId: String(args.marketId) } : {});
1264
+ const orders = await getOrders({
1265
+ privateKey: String(args.privateKey),
1266
+ marketId: args.marketId ? String(args.marketId) : undefined,
1267
+ apiKey: args.apiKey ? String(args.apiKey) : undefined,
1268
+ apiSecret: args.apiSecret ? String(args.apiSecret) : undefined,
1269
+ apiPassphrase: args.apiPassphrase ? String(args.apiPassphrase) : undefined,
1270
+ funder: args.funder ? String(args.funder) : undefined,
1271
+ });
1226
1272
  return { success: true, output: `📋 订单列表: ${JSON.stringify(orders, null, 2).substring(0, 1500)}` };
1227
1273
  }
1228
1274
  catch (e) {
@@ -1232,19 +1278,40 @@ export function registerWalletTools(ctx) {
1232
1278
  });
1233
1279
  ctx.tools.set('polymarket_create_order', {
1234
1280
  name: 'polymarket_create_order',
1235
- description: '在 Polymarket 下单 (BUY/SELL).',
1236
- parameters: { marketId: '市场 ID (必填)', side: 'BUY 或 SELL (必填)', price: '价格 0-1 (必填)', size: '数量 USDC (必填)' },
1281
+ description: '在 Polymarket 下单 (BUY/SELL). 需要钱包私钥 privateKey 做 EIP-712 订单签名与 API key 派生.',
1282
+ parameters: {
1283
+ privateKey: '下单钱包私钥 0x... (必填)',
1284
+ marketId: '市场 ID (必填)',
1285
+ side: 'BUY 或 SELL (必填)',
1286
+ price: '价格 0-1 (必填, 需符合 tickSize)',
1287
+ size: '数量 USDC (必填)',
1288
+ outcome: '可选, Yes/No 或索引 0/1, 默认第一个 (通常 Yes)',
1289
+ tokenId: '可选, 显式条件代币 tokenID (优先于 outcome)',
1290
+ orderType: '可选, GTC (默认) 或 GTD',
1291
+ apiKey: '可选, 已存在的 API key (需配合 apiSecret/apiPassphrase)',
1292
+ apiSecret: '可选, API secret',
1293
+ apiPassphrase: '可选, API passphrase',
1294
+ funder: '可选, 资金地址 (默认=私钥地址)',
1295
+ },
1237
1296
  execute: async (args) => {
1238
1297
  try {
1239
1298
  const { createOrder } = await import('../constraint-runtime/dist/tools/PolymarketSDK/createOrder.js').catch(() => import('../constraint-runtime/src/tools/PolymarketSDK/createOrder.js'));
1240
1299
  const r = await createOrder({
1300
+ privateKey: String(args.privateKey),
1241
1301
  marketId: String(args.marketId),
1242
1302
  side: String(args.side).toUpperCase() === 'SELL' ? 'SELL' : 'BUY',
1243
1303
  price: Number(args.price),
1244
1304
  size: Number(args.size),
1305
+ outcome: args.outcome !== undefined ? args.outcome : undefined,
1306
+ tokenId: args.tokenId ? String(args.tokenId) : undefined,
1307
+ orderType: args.orderType ? String(args.orderType) : undefined,
1308
+ apiKey: args.apiKey ? String(args.apiKey) : undefined,
1309
+ apiSecret: args.apiSecret ? String(args.apiSecret) : undefined,
1310
+ apiPassphrase: args.apiPassphrase ? String(args.apiPassphrase) : undefined,
1311
+ funder: args.funder ? String(args.funder) : undefined,
1245
1312
  });
1246
1313
  if (r.success)
1247
- return { success: true, output: `✅ 订单: ${r.message}` };
1314
+ return { success: true, output: `✅ 订单已提交: orderId=${r.orderId} status=${r.status}` };
1248
1315
  return { success: false, error: r.message, output: r.message };
1249
1316
  }
1250
1317
  catch (e) {
@@ -1254,12 +1321,26 @@ export function registerWalletTools(ctx) {
1254
1321
  });
1255
1322
  ctx.tools.set('polymarket_cancel_order', {
1256
1323
  name: 'polymarket_cancel_order',
1257
- description: '取消 Polymarket 订单.',
1258
- parameters: { orderId: '订单 ID (必填)' },
1324
+ description: '取消 Polymarket 订单. 需要钱包私钥 privateKey 做 API key 鉴权.',
1325
+ parameters: {
1326
+ privateKey: '钱包私钥 0x... (必填)',
1327
+ orderId: '订单 ID (必填)',
1328
+ apiKey: '可选, 已存在的 API key',
1329
+ apiSecret: '可选, API secret',
1330
+ apiPassphrase: '可选, API passphrase',
1331
+ funder: '可选, 资金地址',
1332
+ },
1259
1333
  execute: async (args) => {
1260
1334
  try {
1261
1335
  const { cancelOrder } = await import('../constraint-runtime/dist/tools/PolymarketSDK/cancelOrder.js').catch(() => import('../constraint-runtime/src/tools/PolymarketSDK/cancelOrder.js'));
1262
- const r = await cancelOrder({ orderId: String(args.orderId) });
1336
+ const r = await cancelOrder({
1337
+ privateKey: String(args.privateKey),
1338
+ orderId: String(args.orderId),
1339
+ apiKey: args.apiKey ? String(args.apiKey) : undefined,
1340
+ apiSecret: args.apiSecret ? String(args.apiSecret) : undefined,
1341
+ apiPassphrase: args.apiPassphrase ? String(args.apiPassphrase) : undefined,
1342
+ funder: args.funder ? String(args.funder) : undefined,
1343
+ });
1263
1344
  if (r.success)
1264
1345
  return { success: true, output: `✅ 取消订单: ${r.message}` };
1265
1346
  return { success: false, error: r.message, output: r.message };
@@ -1,6 +1,21 @@
1
+ import { buildClobClient } from './clobShared';
1
2
  export async function cancelOrder(params) {
2
- return {
3
- success: false,
4
- message: 'Polymarket order cancellation requires CLOB client with authentication. Use the Polymarket web interface to cancel orders.',
5
- };
3
+ if (!params.privateKey) {
4
+ return { success: false, message: '取消订单需要提供钱包私钥 privateKey' };
5
+ }
6
+ if (!params.orderId) {
7
+ return { success: false, message: 'orderId 必填' };
8
+ }
9
+ try {
10
+ const { client } = await buildClobClient({
11
+ privateKey: params.privateKey,
12
+ creds: { apiKey: params.apiKey, apiSecret: params.apiSecret, apiPassphrase: params.apiPassphrase },
13
+ funder: params.funder,
14
+ });
15
+ const resp = await client.cancelOrder({ orderID: params.orderId });
16
+ return { success: true, message: '已提交取消', raw: resp };
17
+ }
18
+ catch (e) {
19
+ return { success: false, message: `取消失败: ${e?.message || e}` };
20
+ }
6
21
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Polymarket CLOB 下单/查单/撤单 共享依赖
3
+ *
4
+ * 真实实现基于 @polymarket/clob-client (ClobClient):
5
+ * - 签名用 viem WalletClient (privateKeyToAccount + polygon + http transport)
6
+ * - 下单前需 ApiKeyCreds (key/secret/passphrase), 由 createOrDeriveApiKey() 从签名派生
7
+ * - tokenID / tickSize / negRisk 来自 Gamma 市场元数据 (https://gamma-api.polymarket.com/markets/:id)
8
+ *
9
+ * chainId = 137 (Polygon), host = https://clob.polymarket.com
10
+ */
11
+ import { ClobClient, OrderType, Side } from '@polymarket/clob-client';
12
+ import { createWalletClient, http } from 'viem';
13
+ import { privateKeyToAccount } from 'viem/accounts';
14
+ import { polygon } from 'viem/chains';
15
+ export const CLOB_HOST = 'https://clob.polymarket.com';
16
+ export const CHAIN_ID = 137;
17
+ export function normalizePrivateKey(pk) {
18
+ const s = pk.startsWith('0x') ? pk : `0x${pk}`;
19
+ return s;
20
+ }
21
+ function safeParseArray(v) {
22
+ if (Array.isArray(v))
23
+ return v;
24
+ if (typeof v === 'string') {
25
+ try {
26
+ const parsed = JSON.parse(v);
27
+ return Array.isArray(parsed) ? parsed : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ return [];
34
+ }
35
+ /**
36
+ * 从 Gamma 取市场元数据: clobTokenIds / outcomes / tickSize / negRisk.
37
+ * 优先用 Gamma 路径端点 (带 tickSize/negRisk), 失败回退 polymarket-sdk listMarkets({id}).
38
+ */
39
+ export async function fetchMarketMeta(marketId) {
40
+ try {
41
+ const res = await fetch(`https://gamma-api.polymarket.com/markets/${encodeURIComponent(marketId)}`);
42
+ if (res.ok) {
43
+ const m = await res.json();
44
+ return {
45
+ clobTokenIds: safeParseArray(m.clobTokenIds),
46
+ outcomes: safeParseArray(m.outcomes),
47
+ tickSize: typeof m.tickSize === 'string' ? m.tickSize : '0.01',
48
+ negRisk: !!m.negRisk,
49
+ };
50
+ }
51
+ }
52
+ catch {
53
+ // fall through to sdk
54
+ }
55
+ const { listMarkets } = await import('polymarket-sdk').catch(() => ({ listMarkets: async () => [] }));
56
+ const ms = await listMarkets({ id: marketId });
57
+ const m = ms?.[0];
58
+ if (!m)
59
+ throw new Error(`未找到市场: ${marketId}`);
60
+ return {
61
+ clobTokenIds: safeParseArray(m.clobTokenIds),
62
+ outcomes: safeParseArray(m.outcomes),
63
+ tickSize: '0.01',
64
+ negRisk: false,
65
+ };
66
+ }
67
+ /**
68
+ * 由 outcome (如 "Yes"/"No" 或索引 0/1) 或显式 tokenId 解析出要交易的 tokenID.
69
+ * 都不给 → 默认取第一个 clobTokenId (通常是 "Yes").
70
+ */
71
+ export function resolveTokenId(meta, tokenId, outcome) {
72
+ if (tokenId)
73
+ return tokenId;
74
+ if (!meta.clobTokenIds || meta.clobTokenIds.length === 0)
75
+ return undefined;
76
+ if (outcome === undefined || outcome === null)
77
+ return meta.clobTokenIds[0];
78
+ if (typeof outcome === 'number')
79
+ return meta.clobTokenIds[outcome];
80
+ const idx = meta.outcomes.findIndex((o) => String(o).toLowerCase() === String(outcome).toLowerCase());
81
+ return idx >= 0 ? meta.clobTokenIds[idx] : undefined;
82
+ }
83
+ /**
84
+ * 构造已鉴权的 ClobClient.
85
+ * - 若提供完整 apiKey/apiSecret/apiPassphrase → 直接用
86
+ * - 否则用签名派生 ApiKey (createOrDeriveApiKey, 需联网)
87
+ * signatureType = 0 (EOA / 浏览器钱包, 对应原始私钥签名)
88
+ */
89
+ export async function buildClobClient(params) {
90
+ const account = privateKeyToAccount(normalizePrivateKey(params.privateKey));
91
+ const signer = createWalletClient({ account, chain: polygon, transport: http() });
92
+ const address = account.address;
93
+ let creds;
94
+ if (params.creds?.apiKey && params.creds?.apiSecret && params.creds?.apiPassphrase) {
95
+ creds = { key: params.creds.apiKey, secret: params.creds.apiSecret, passphrase: params.creds.apiPassphrase };
96
+ }
97
+ if (!creds) {
98
+ const tmp = new ClobClient(CLOB_HOST, CHAIN_ID, signer);
99
+ creds = await tmp.createOrDeriveApiKey();
100
+ }
101
+ const client = new ClobClient(CLOB_HOST, CHAIN_ID, signer, creds, 0, params.funder ?? address);
102
+ return { client, address };
103
+ }
104
+ export { OrderType, Side };
@@ -1,6 +1,36 @@
1
+ import { buildClobClient, fetchMarketMeta, resolveTokenId } from './clobShared';
1
2
  export async function createOrder(params) {
2
- return {
3
- success: false,
4
- message: 'Polymarket order creation requires CLOB client with authentication. Use the Polymarket web interface to create orders.',
5
- };
3
+ if (!params.privateKey) {
4
+ return { success: false, message: '下单需要提供钱包私钥 privateKey (用于 EIP-712 订单签名)' };
5
+ }
6
+ if (!params.marketId) {
7
+ return { success: false, message: 'marketId 必填' };
8
+ }
9
+ try {
10
+ const meta = await fetchMarketMeta(params.marketId);
11
+ const tokenID = resolveTokenId(meta, params.tokenId, params.outcome);
12
+ if (!tokenID) {
13
+ return { success: false, message: '无法解析 tokenID (outcome 不匹配或市场无 clobTokenIds)' };
14
+ }
15
+ const { client } = await buildClobClient({
16
+ privateKey: params.privateKey,
17
+ creds: { apiKey: params.apiKey, apiSecret: params.apiSecret, apiPassphrase: params.apiPassphrase },
18
+ funder: params.funder,
19
+ });
20
+ const resp = await client.createAndPostOrder({
21
+ tokenID,
22
+ price: Number(params.price),
23
+ size: Number(params.size),
24
+ side: params.side,
25
+ }, { tickSize: meta.tickSize, negRisk: meta.negRisk }, (params.orderType ?? 'GTC'));
26
+ return {
27
+ success: true,
28
+ orderId: resp?.orderID ?? resp?.id,
29
+ status: resp?.status,
30
+ raw: resp,
31
+ };
32
+ }
33
+ catch (e) {
34
+ return { success: false, message: `下单失败: ${e?.message || e}` };
35
+ }
6
36
  }
@@ -1,6 +1,19 @@
1
+ import { buildClobClient } from './clobShared';
1
2
  export async function getOrders(params = {}) {
2
- return {
3
- orders: [],
4
- message: 'Polymarket order retrieval requires CLOB client with authentication. Use the Polymarket web interface to view orders.',
5
- };
3
+ if (!params.privateKey) {
4
+ return { orders: [], message: '查询订单需要提供钱包私钥 privateKey' };
5
+ }
6
+ try {
7
+ const { client } = await buildClobClient({
8
+ privateKey: params.privateKey,
9
+ creds: { apiKey: params.apiKey, apiSecret: params.apiSecret, apiPassphrase: params.apiPassphrase },
10
+ funder: params.funder,
11
+ });
12
+ const resp = await client.getOpenOrders(params.marketId ? { market: params.marketId } : {});
13
+ const orders = Array.isArray(resp) ? resp : resp?.orders ?? [];
14
+ return { orders, message: 'OK' };
15
+ }
16
+ catch (e) {
17
+ return { orders: [], message: `查询失败: ${e?.message || e}` };
18
+ }
6
19
  }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * external-engines/delegate.ts — 把编码任务委派给本机已安装的外部编码智能体 CLI
3
+ *
4
+ * 与 discovery 配合: discovery 找到 CLI 路径 + 规格, 这里真正 spawn 起来跑任务.
5
+ *
6
+ * 安全边界 (参照 shell-tool.ts 的护栏思路):
7
+ * - 只委派给"已发现且 installed"的引擎 (cliPath 来自 command -v, 不由用户输入)
8
+ * - prompt 作为单一 argv 传入, shell: false, 杜绝命令注入
9
+ * - 默认 120s 超时 (BOLLOON_ENGINE_DELEGATE_TIMEOUT_MS 可配), 超时杀进程
10
+ * - experiment 引擎是 API 供应商不是 CLI, 不支持委派 (提示改用 import)
11
+ */
12
+ import { spawn } from 'child_process';
13
+ import { discoverEngines, buildDelegateArgs } from './discovery.js';
14
+ function delegateTimeoutMs() {
15
+ const env = Number(process.env.BOLLOON_ENGINE_DELEGATE_TIMEOUT_MS);
16
+ return Number.isFinite(env) && env > 0 ? env : 120_000;
17
+ }
18
+ /**
19
+ * 把任务派发给指定引擎的 CLI 执行.
20
+ * @param id 引擎 id: codex / claude-code / opencode / openclaw / hermes
21
+ * @param prompt 任务描述 (作为单参数传给 CLI)
22
+ */
23
+ export async function delegateToEngine(id, prompt, opts = {}) {
24
+ const trimmedId = String(id || '').trim();
25
+ const trimmedPrompt = String(prompt || '').trim();
26
+ if (!trimmedId)
27
+ return { success: false, error: 'engine id 必填', unavailable: true };
28
+ if (!trimmedPrompt)
29
+ return { success: false, error: 'prompt 必填', unavailable: false };
30
+ // 实验引擎是 API 供应商, 不是 CLI, 不能委派
31
+ if (trimmedId.startsWith('experiment:')) {
32
+ return {
33
+ success: false,
34
+ unavailable: true,
35
+ error: `引擎 ${trimmedId} 是实验 API 供应商 (无 CLI), 不能委派执行. 请先用 /api/external-engines/import 把它注册为 LLM provider 后由 Bolloon 直接调用.`,
36
+ };
37
+ }
38
+ // 发现引擎, 拿到 cliPath + argv 模板
39
+ const engines = await discoverEngines();
40
+ const engine = engines.find((e) => e.id === trimmedId);
41
+ if (!engine) {
42
+ return { success: false, unavailable: true, error: `未发现的引擎: ${trimmedId}` };
43
+ }
44
+ if (!engine.installed || !engine.cliPath) {
45
+ return {
46
+ success: false,
47
+ unavailable: true,
48
+ error: `引擎 ${trimmedId} 未安装 (CLI 不在 PATH 上), 无法委派. 可用 /api/external-engines 查看已安装列表.`,
49
+ };
50
+ }
51
+ const argv = buildDelegateArgs(trimmedId, trimmedPrompt, opts.model);
52
+ if (!argv) {
53
+ return { success: false, unavailable: true, error: `引擎 ${trimmedId} 没有配置委派参数模板` };
54
+ }
55
+ const cwd = opts.cwd || process.cwd();
56
+ const timeoutMs = opts.timeoutMs || delegateTimeoutMs();
57
+ return new Promise((resolve) => {
58
+ let stdout = '';
59
+ let stderr = '';
60
+ let settled = false;
61
+ // 注意: 不要用 detached:true — 实测会让 opencode 不退出 (探针: detached=true 时 90s 仍不 exit,
62
+ // detached=false 时 ~11s 正常 exit+close). opencode run --format json 退出干净, 无残留孙进程.
63
+ // 监听 'exit' 而非 'close': exit 在进程退出时即触发, 更稳 (close 也正常, 两者都可用).
64
+ const proc = spawn(engine.cliPath, argv, {
65
+ cwd,
66
+ env: { ...process.env },
67
+ shell: false,
68
+ windowsHide: true,
69
+ // stdin 必须 'ignore' (/dev/null): 否则 stdin 是默认管道, opencode run 会阻塞等
70
+ // stdin EOF 导致永不退出. stdout/stderr 用 pipe 收集输出.
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ });
73
+ const killTree = () => {
74
+ try {
75
+ proc.kill('SIGKILL');
76
+ }
77
+ catch {
78
+ // 进程可能已退出, 忽略
79
+ }
80
+ };
81
+ const killTimer = setTimeout(() => {
82
+ if (!settled) {
83
+ settled = true;
84
+ killTree();
85
+ resolve({
86
+ success: false,
87
+ output: stdout.slice(-8000),
88
+ error: `委派超时 (${timeoutMs}ms), 已终止 ${trimmedId} 进程`,
89
+ exitCode: null,
90
+ });
91
+ }
92
+ }, timeoutMs);
93
+ proc.stdout?.on('data', (d) => {
94
+ stdout += d.toString();
95
+ if (stdout.length > 8_000_000) {
96
+ // 超过 8MB, 截断防止内存爆炸 (仍继续收集尾部不重要)
97
+ stdout = stdout.slice(-8_000_000);
98
+ }
99
+ });
100
+ proc.stderr?.on('data', (d) => {
101
+ stderr += d.toString();
102
+ if (stderr.length > 8_000_000)
103
+ stderr = stderr.slice(-8_000_000);
104
+ });
105
+ proc.on('error', (err) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ clearTimeout(killTimer);
110
+ killTree();
111
+ resolve({ success: false, error: `启动 ${trimmedId} 失败: ${err.message}`, exitCode: null, unavailable: true });
112
+ });
113
+ // 用 'exit' 而非 'close': exit 在进程退出时即触发, 不被孙子进程持有的管道阻塞.
114
+ // setImmediate 给最后一批 stdout data 一个 tick 的 flush 机会, 避免截断.
115
+ proc.on('exit', (code, signal) => {
116
+ if (settled)
117
+ return;
118
+ setImmediate(() => {
119
+ if (settled)
120
+ return;
121
+ settled = true;
122
+ clearTimeout(killTimer);
123
+ // opencode run 会留一个 headless server 孙进程继承 stdout 管道, 让 Node 的
124
+ // 'close' 永不触发 / 事件循环不退出. destroy 掉我们这一侧的流句柄, 释放 event loop
125
+ // (孙进程的 fd 副本在它自己进程里, 不影响 Node 退出). 结果已在 stdout/stderr 字符串里.
126
+ try {
127
+ proc.stdout?.destroy();
128
+ }
129
+ catch { /* noop */ }
130
+ try {
131
+ proc.stderr?.destroy();
132
+ }
133
+ catch { /* noop */ }
134
+ killTree();
135
+ const combined = (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim();
136
+ if (code === 0) {
137
+ resolve({ success: true, output: combined || '(无输出)', exitCode: code });
138
+ }
139
+ else if (signal) {
140
+ resolve({ success: false, output: combined || '(无输出)', error: `${trimmedId} 被信号 ${signal} 终止`, exitCode: null });
141
+ }
142
+ else {
143
+ resolve({ success: false, output: combined || '(无输出)', error: `${trimmedId} 退出码 ${code}`, exitCode: code });
144
+ }
145
+ });
146
+ });
147
+ });
148
+ }