// opencode 直连 shell 派发器(R115 修正 B-113 误判:session 路由 204 是设计行为,不是失败)
// 关键事实(openapi.json 实证):
// 1. POST /api/shell?location[directory]=
← 正确入口,直接返回 Shell.Info(含 id)
// 2. POST /api/session/{id}/shell 恒返 204(无 body),且 shell 退出后即被 GC
// → 旧脚本轮询 /api/shell(list 只列 running)必然拿到 NONE,误判为「建不出对象」
// 3. 长任务必须「重定向到日志文件 + 另起 shell 读文件」,否则 shell 对象被回收后
// /api/shell/{id}/output 返回 ShellNotFoundError
// 用法(Git Bash 必须带 MSYS_NO_PATHCONV=1,否则 /sc-data 会被改写成本机路径):
// MSYS_NO_PATHCONV=1 BASE=http://192.168.16.251:4096 DIR=/sc-data/cfc/cfc-langgraph \
// CMD="pytest -q" LOG=/tmp/x.log node dispatch_shell.cjs
const BASE = process.env.BASE;
const DIR = process.env.DIR;
const CMD = process.env.CMD;
const LOG = process.env.LOG || '/tmp/dispatch_shell.log';
const CAP_MS = parseInt(process.env.CAP_MS || '900000', 10);
const AUTH = 'Basic ' + Buffer.from('opencode:' + (process.env.OPENCODE_SERVER_PASSWORD || 'IwinTrue@123')).toString('base64');
const H = { Authorization: AUTH, 'Content-Type': 'application/json' };
const LOC = '?location%5Bdirectory%5D=' + encodeURIComponent(DIR || '');
async function j(url, opt) {
try {
const r = await fetch(url, Object.assign({ headers: H }, opt));
const t = await r.text();
try { return { status: r.status, json: JSON.parse(t) }; } catch { return { status: r.status, text: t }; }
} catch (e) { return { status: 0, err: e.message }; }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function run(cmd, capMs) {
const cr = await j(BASE + '/api/shell' + LOC, { method: 'POST', body: JSON.stringify({ command: cmd, cwd: DIR }) });
const id = cr.json && cr.json.data && cr.json.data.id;
if (!id) return { err: 'create failed ' + cr.status + ' ' + JSON.stringify(cr).slice(0, 200) };
const t0 = Date.now();
while (Date.now() - t0 < capMs) {
await sleep(3000);
const s = await j(BASE + '/api/shell/' + id + LOC);
const d = (s.json && s.json.data) || null;
if (!d) continue; // GC/竞态:继续等下一次
if (d.status && d.status !== 'running') {
const o = await j(BASE + '/api/shell/' + id + '/output' + LOC + '&limit=200000');
return { status: d.status, out: (o.json && o.json.data && o.json.data.output) || '' };
}
}
return { err: 'timeout ' + capMs + 'ms' };
}
(async () => {
// 1) 后台启动长任务并写盘(避免 shell 对象被回收)
const kick = await run('rm -f ' + LOG + '; ( ' + CMD + ' ) > ' + LOG + ' 2>&1; echo DONE_$? >> ' + LOG, 30000);
console.log('KICK', kick.err || kick.status);
// 2) 轮询日志文件
const t0 = Date.now();
let tail = '';
while (Date.now() - t0 < CAP_MS) {
await sleep(8000);
const r = await run('tail -60 ' + LOG, 45000);
if (r.err) { console.log('POLL_ERR', r.err); continue; }
tail = r.out || '';
if (/DONE_\d+/.test(tail)) break;
}
console.log('OUT_BEGIN');
console.log(tail);
console.log('OUT_END');
})().catch(e => { console.error('ERR', e.message); process.exit(1); });