open-tui-orchestrator 0.9.6

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/INSTALL-zh.md +96 -0
  3. package/INSTALL.md +96 -0
  4. package/LICENSE +48 -0
  5. package/README-zh.md +181 -0
  6. package/README.md +181 -0
  7. package/cli.mjs +37 -0
  8. package/docs/adapt.md +103 -0
  9. package/docs/assets/kimicode-agent-swarm-10-subagents.png +0 -0
  10. package/docs/auto-recovery.md +23 -0
  11. package/docs/caller-driven.md +121 -0
  12. package/docs/claude-adapter.md +25 -0
  13. package/docs/execution-contract.md +70 -0
  14. package/docs/inactive-windows.md +11 -0
  15. package/docs/kimi-adapter.md +27 -0
  16. package/docs/kimi-integration.md +56 -0
  17. package/docs/maintenance-lock.md +32 -0
  18. package/docs/openclaw-adapter.md +59 -0
  19. package/docs/openclaw-assessment-2026-09-06.md +59 -0
  20. package/docs/opencode-adapter.md +25 -0
  21. package/docs/pi-adapter.md +58 -0
  22. package/docs/public-readiness.md +63 -0
  23. package/docs/release-policy.md +39 -0
  24. package/docs/security-audit-2026-09-09.md +41 -0
  25. package/docs/trust-and-safety.md +64 -0
  26. package/docs/verification-2026-09-06.md +22 -0
  27. package/docs/verification-recovery-2026-09-06.md +36 -0
  28. package/orch.mjs +20 -0
  29. package/package.json +36 -0
  30. package/release.json +116 -0
  31. package/repair.mjs +228 -0
  32. package/scripts/adapt.mjs +35 -0
  33. package/scripts/agent-auth-prompt.txt +10 -0
  34. package/scripts/agent.mjs +1 -0
  35. package/scripts/core/adapt-lib.mjs +219 -0
  36. package/scripts/core/agent-auth-prompt.txt +10 -0
  37. package/scripts/core/agent-profiles/hermes.json +59 -0
  38. package/scripts/core/agent.mjs +1 -0
  39. package/scripts/core/checkpoint.mjs +38 -0
  40. package/scripts/core/claude-host.mjs +50 -0
  41. package/scripts/core/claude-runtime.mjs +111 -0
  42. package/scripts/core/contracts.mjs +161 -0
  43. package/scripts/core/host-cli.mjs +204 -0
  44. package/scripts/core/host-model.mjs +323 -0
  45. package/scripts/core/host-probe.mjs +16 -0
  46. package/scripts/core/inactive-window.mjs +32 -0
  47. package/scripts/core/inactive-window.ps1 +36 -0
  48. package/scripts/core/kimi-host.mjs +41 -0
  49. package/scripts/core/kimi-runtime.mjs +140 -0
  50. package/scripts/core/lease-lock.ps1 +32 -0
  51. package/scripts/core/leases.mjs +176 -0
  52. package/scripts/core/maintenance-lock.mjs +77 -0
  53. package/scripts/core/native-argv.mjs +9 -0
  54. package/scripts/core/network-policy.mjs +18 -0
  55. package/scripts/core/openclaw-bootstrap.mjs +25 -0
  56. package/scripts/core/openclaw-config.mjs +35 -0
  57. package/scripts/core/openclaw-host.mjs +29 -0
  58. package/scripts/core/openclaw-runtime.mjs +33 -0
  59. package/scripts/core/openclaw-window.mjs +44 -0
  60. package/scripts/core/opencode-host.mjs +80 -0
  61. package/scripts/core/opencode-runtime.mjs +131 -0
  62. package/scripts/core/orchestrate-sdk.mjs +2595 -0
  63. package/scripts/core/pi-host.mjs +29 -0
  64. package/scripts/core/pi-runtime.mjs +54 -0
  65. package/scripts/core/pi-shutdown.mjs +16 -0
  66. package/scripts/core/poll-windows.mjs +48 -0
  67. package/scripts/core/print-profile.mjs +79 -0
  68. package/scripts/core/print-runtime.mjs +106 -0
  69. package/scripts/core/pty-host.mjs +38 -0
  70. package/scripts/core/recovery.mjs +75 -0
  71. package/scripts/core/run-board.mjs +155 -0
  72. package/scripts/core/run-guardian.mjs +130 -0
  73. package/scripts/core/runner.mjs +274 -0
  74. package/scripts/core/runtime-context.mjs +23 -0
  75. package/scripts/core/unit-carrier.mjs +55 -0
  76. package/scripts/core/unit-command.mjs +96 -0
  77. package/scripts/core/unit-runtime.mjs +107 -0
  78. package/scripts/gate.mjs +162 -0
  79. package/scripts/host-cli.mjs +2 -0
  80. package/scripts/install-deps.mjs +58 -0
  81. package/scripts/maintenance-lock.mjs +46 -0
  82. package/scripts/network-policy.mjs +2 -0
  83. package/scripts/open-tui-orchestrator-force.mjs +239 -0
  84. package/scripts/open-tui-orchestrator-preflight.mjs +85 -0
  85. package/scripts/orchestrate-sdk.mjs +59 -0
  86. package/scripts/package-lock.json +242 -0
  87. package/scripts/package.json +9 -0
  88. package/scripts/platform-guard.mjs +23 -0
  89. package/scripts/poll-windows.mjs +8 -0
  90. package/scripts/release-integrity.mjs +94 -0
  91. package/scripts/runtime-context.mjs +2 -0
  92. package/scripts/sdk-dependency-check.mjs +32 -0
  93. package/scripts/todo-list.mjs +89 -0
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {spawn,spawnSync} from 'node:child_process';
6
+ import {keysOverlap,powershellExe} from './contracts.mjs';
7
+
8
+ // 锚定 SystemRoot 的 powershell.exe:裸名会先按进程 cwd(任意工作区)解析,防二进制投毒。
9
+ const PS_EXE=powershellExe();
10
+
11
+ export function processSnapshot(timeoutMs=15000) {
12
+ if(process.platform!=='win32')throw new Error('Visible TUI runtime currently requires Windows');
13
+ const result=spawnSync(PS_EXE,['-NoProfile','-Command',
14
+ '$ErrorActionPreference="Stop"; Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine,@{n="Started";e={$_.CreationDate.ToUniversalTime().ToString("o")}} | ConvertTo-Json -Compress'],
15
+ {encoding:'utf8',windowsHide:true,timeout:Math.max(1,Math.min(15000,Math.ceil(timeoutMs)))});
16
+ if(result.status!==0)throw new Error('Cannot verify process identities');
17
+ return new Map(JSON.parse(result.stdout).map(p=>[p.ProcessId,p]));
18
+ }
19
+ export function sameProcess(expected,actual) {
20
+ return !!expected&&!!actual&&expected.ProcessId===actual.ProcessId&&expected.Started===actual.Started&&expected.Name===actual.Name&&expected.CommandLine===actual.CommandLine;
21
+ }
22
+ export function launcherIdentity(pid,pidFile,snapshot=processSnapshot()) {
23
+ const actual=snapshot.get(Number(pid));
24
+ const script=path.join(path.dirname(pidFile),path.basename(pidFile).replace(/^window-/,'win-launch-').replace(/\.pid$/,'.ps1'));
25
+ const args=String(actual?.CommandLine||'').toLowerCase().replaceAll('/','\\');
26
+ if(!/^(powershell|pwsh)\.exe$/i.test(actual?.Name||'')||!args.includes(script.toLowerCase().replaceAll('/','\\')))return null;
27
+ return actual;
28
+ }
29
+
30
+ // Only a locator is kept in HKCU; all runtime files remain beneath the first
31
+ // initiating workspace's temp. A named mutex makes first discovery atomic.
32
+ // Under `node --test` (NODE_TEST_CONTEXT is set) the machine-wide locator is
33
+ // never touched: test workspaces keep their coordinator in their own temp.
34
+ export function coordinatorDir(workspace,env=process.env) {
35
+ const resolved=(()=>{
36
+ if(env.ORCH_COORDINATOR_DIR)return path.resolve(workspace,env.ORCH_COORDINATOR_DIR);
37
+ const fallback=path.join(workspace,'temp','orchestrator','coordinator');
38
+ if(process.platform!=='win32'||env.NODE_TEST_CONTEXT)return fallback;
39
+ const quoted=fallback.replaceAll("'","''");
40
+ const script=`$held=$false; $m=[Threading.Mutex]::new($false,'Local\\TuiOrchestrator.Coordinator'); try {try{$held=$m.WaitOne(15000)}catch [Threading.AbandonedMutexException]{$held=$true}; if(-not $held){throw 'Coordinator locator busy'}; $key='HKCU:\\Software\\TuiOrchestrator'; $p=(Get-ItemProperty -LiteralPath $key -Name CoordinatorRoot -ErrorAction SilentlyContinue).CoordinatorRoot; if(-not $p){$p='${quoted}'; New-Item -Path $key -Force|Out-Null; New-ItemProperty -LiteralPath $key -Name CoordinatorRoot -Value $p -PropertyType String -Force|Out-Null}; Write-Output $p}finally{if($held){$m.ReleaseMutex()};$m.Dispose()}`;
41
+ const r=spawnSync(PS_EXE,['-NoProfile','-Command',script],{encoding:'utf8',windowsHide:true,timeout:20000});
42
+ if(r.status!==0||!r.stdout.trim())throw new Error('Unable to locate machine window coordinator');
43
+ return path.resolve(r.stdout.trim());
44
+ })();
45
+ // 入口即回收:被强杀的 PS 不会执行自己的清理,过期锁票会一直躺在协调器目录里(本轮清理时实测
46
+ // 576 个)。这里每次解析协调器目录时顺手按龄回收一次(只删老旧票,绝不碰在票)——保证空闲时
47
+ // 也不会长期积压,而不是等到下一次事务才清。
48
+ if(fs.existsSync(resolved))sweepStaleMutexTickets(resolved);
49
+ return resolved;
50
+ }
51
+ // 协调器内核互斥的口径(全部可调;2026-09-14 修:旧值把等待写死 20s,满载多竞争者会假失败):
52
+ // - ORCH_MUTEX_WAIT_MS PS 侧 WaitOne 时长,默认 60000ms;
53
+ // - ORCH_MUTEX_RETRIES 获取失败后的重试次数,默认 2(退避复用 waitBackoffMs);
54
+ // - ORCH_MUTEX_TICKET_TTL_MS 锁票老化回收阈值,默认 600000ms。
55
+ // 被强杀/超时的 PS 进程跑不到自己的清理(finally 只在本进程里),锁票会在协调器目录无界堆积
56
+ // (2026-09-14 实测 576 个)——所以每次事务前按龄回收一次:只收老旧票,绝不碰在票。
57
+ const sleepFor=ms=>{try{Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);}catch{}};
58
+ function mutexWaitMs(){const v=Number(process.env.ORCH_MUTEX_WAIT_MS);return Number.isFinite(v)&&v>=1000?Math.floor(v):60000;}
59
+ function mutexRetries(){const v=Number(process.env.ORCH_MUTEX_RETRIES);return Number.isFinite(v)&&v>=0?Math.min(10,Math.floor(v)):2;}
60
+ function mutexTicketTtlMs(){const v=Number(process.env.ORCH_MUTEX_TICKET_TTL_MS);return Number.isFinite(v)&&v>=0?Math.floor(v):600000;}
61
+ export function sweepStaleMutexTickets(dir,{ttlMs=mutexTicketTtlMs(),now=Date.now()}={}){
62
+ let removed=0;
63
+ try{
64
+ for(const name of fs.readdirSync(dir)){
65
+ if(!/^lock-[0-9a-f-]+\.(ready|release)$/.test(name))continue;
66
+ const full=path.join(dir,name);
67
+ try{if(now-fs.statSync(full).mtimeMs>=ttlMs){fs.rmSync(full,{force:true});removed++;}}catch{}
68
+ }
69
+ }catch{}
70
+ return removed;
71
+ }
72
+ // 取一次内核互斥:PS 侧等 waitMs,JS 侧按 waitMs + 启动/退出余量设界。
73
+ // 注意:等待是同步阻塞的(Atomics.wait),事件回调在阻塞期间不会运行——所以这里**不能**依赖
74
+ // 'exit' 事件判早退(旧实现就是干等满 30s)。失败时按 waitMs 有界返回,由调用方决定重试。
75
+ function acquireCoordinatorMutex(dir,owner,waitMs,script){
76
+ const ticket=path.join(dir,'lock-'+crypto.randomUUID());
77
+ const child=spawn(PS_EXE,['-NoProfile','-File',script,'-Directory',dir,'-Ticket',ticket,'-Owner',String(owner.ProcessId),'-Started',owner.Started],{windowsHide:true,stdio:'ignore'});
78
+ child.on('error',()=>{});child.unref();
79
+ const deadline=Date.now()+waitMs+2500;
80
+ while(!fs.existsSync(ticket+'.ready')&&Date.now()<deadline)sleepFor(25);
81
+ const ready=fs.existsSync(ticket+'.ready');
82
+ const release=()=>{try{fs.writeFileSync(ticket+'.release','release');}catch{}};
83
+ const discard=()=>{for(const suffix of ['.ready','.release'])try{fs.rmSync(ticket+suffix,{force:true});}catch{}};
84
+ return {ready,release,discard};
85
+ }
86
+ export function atomicJson(file,value){
87
+ fs.mkdirSync(path.dirname(file),{recursive:true});
88
+ const tmp=file+'.'+process.pid+'.tmp';fs.writeFileSync(tmp,JSON.stringify(value,null,2));
89
+ // Windows: renameSync 不覆盖已存在目标,且目标被杀软/索引器瞬时占用时报 EPERM/EACCES。
90
+ // 单次 remove+rename 仍有窗口,这里做短暂退避重试;非暂时性错误立即上抛,临时文件不残留。
91
+ const sleepSync=sleepFor;
92
+ let lastError=null;
93
+ for(let attempt=0;attempt<6;attempt++){
94
+ try{fs.renameSync(tmp,file);return;}
95
+ catch(error){
96
+ lastError=error;
97
+ if(!error||!['EEXIST','EPERM','EACCES'].includes(error.code))break;
98
+ try{fs.rmSync(file,{force:true});}catch{}
99
+ sleepSync(60+attempt*80);
100
+ }
101
+ }
102
+ try{fs.rmSync(tmp,{force:true});}catch{}
103
+ throw lastError;
104
+ }
105
+ export function transaction(dir,fn,snapshot=processSnapshot) {
106
+ fs.mkdirSync(dir,{recursive:true});const lock=path.join(dir,'mutex.json'),file=path.join(dir,'leases.json');
107
+ let processes=snapshot();const owner=processes.get(process.pid);
108
+ if(!owner)throw new Error('Coordinator owner identity unavailable');
109
+ // A kernel mutex is released by Windows on a crash. Unlike a stale lockfile,
110
+ // it cannot be reclaimed by two contenders or strand an empty lock forever.
111
+ let unlock=()=>{};
112
+ if(snapshot===processSnapshot) {
113
+ sweepStaleMutexTickets(dir);
114
+ const waitMs=mutexWaitMs(),tries=mutexRetries();
115
+ const script=fileURLToPath(new URL('./lease-lock.ps1',import.meta.url));
116
+ let held=null;
117
+ for(let attempt=0;;attempt++) {
118
+ const candidate=acquireCoordinatorMutex(dir,owner,waitMs,script);
119
+ if(candidate.ready){held=candidate;break;}
120
+ candidate.release();
121
+ // 失败路径的锁票清理延后到事件循环恢复之后(阻塞等待期间 setTimeout 也不会跑);
122
+ // 真正兜底的是 sweepStaleMutexTickets 的按龄回收。
123
+ setTimeout(candidate.discard,3000).unref?.();
124
+ if(attempt>=tries)throw new Error('Coordinator mutex acquisition failed (waited '+waitMs+'ms x'+(attempt+1)+'; tune ORCH_MUTEX_WAIT_MS / ORCH_MUTEX_RETRIES)');
125
+ sleepFor(waitBackoffMs(attempt,{base:250,max:2000}));
126
+ }
127
+ unlock=()=>held.release();
128
+ }
129
+ try {processes=snapshot();let leases=[];try{leases=JSON.parse(fs.readFileSync(file,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}
130
+ leases=leases.filter(l=>{
131
+ if(l.launcher)return sameProcess(l.launcher,processes.get(l.launcher.ProcessId));
132
+ if(l.pidFile){let pid=0;try{pid=Number(fs.readFileSync(l.pidFile,'utf8').replace(/^\uFEFF/,''));}catch{};const found=launcherIdentity(pid,l.pidFile,processes);if(found){l.launcher=found;return true;}}
133
+ return sameProcess(l.owner,processes.get(l.owner.ProcessId))||Date.now()-(l.reservedAt||0)<90000;
134
+ });
135
+ const result=fn(leases,owner,processes);atomicJson(file,leases);return result;
136
+ }finally{unlock();}
137
+ }
138
+ // 排队退避(P1):容量/冲突等待按指数退避 + 抖动——多等待者错峰,避免每秒各打一发协调器事务的惊群。
139
+ // attempt 从 0 起;base=首等毫秒、max=封顶(抖动上界 max×1.2);random 可注入以保证测试确定性。
140
+ export function waitBackoffMs(attempt, {base = 600, max = 5000, random = Math.random} = {}) {
141
+ const grow = Math.max(0, Math.floor(Number(attempt) || 0));
142
+ const cap = Math.max(1, Number(max) || 1);
143
+ const start = Math.min(Math.max(1, Number(base) || 1), cap);
144
+ const target = Math.min(cap, start * Math.pow(1.7, grow));
145
+ const jitter = 0.8 + (typeof random === 'function' ? Number(random()) : Math.random()) * 0.4;
146
+ return Math.max(1, Math.round(target * jitter));
147
+ }
148
+ export function reserve(dir,rec,capacity,snapshot) {
149
+ return transaction(dir,(leases,owner)=>{
150
+ if(leases.some(l=>l.id===rec.id))return {ok:false,reason:'existing reservation'};
151
+ const limit=Math.min(capacity,...leases.map(l=>l.capacity||capacity));
152
+ if(leases.length>=limit)return {ok:false,reason:'capacity'};
153
+ if(leases.some(l=>keysOverlap(l.keys,rec.keys)))return {ok:false,reason:'conflict'};
154
+ leases.push({...rec,owner,capacity,reservedAt:Date.now()});return {ok:true};
155
+ },snapshot);
156
+ }
157
+ export function bind(dir,id,launcher,snapshot){return transaction(dir,leases=>{const l=leases.find(l=>l.id===id);if(!l)throw new Error('Reservation expired');l.launcher=launcher;},snapshot);}
158
+ export function release(dir,id,snapshot){return transaction(dir,leases=>{const index=leases.findIndex(l=>l.id===id);if(index>=0)leases.splice(index,1);},snapshot);}
159
+
160
+ export function prepare(dir,id,pidFile){return transaction(dir,leases=>{const l=leases.find(l=>l.id===id);if(!l)throw new Error("Reservation expired");l.pidFile=pidFile;});}
161
+
162
+ export function claimRun(dir,key) {
163
+ return transaction(dir,(_leases,owner,processes)=>{
164
+ const file=path.join(dir,'run-owners.json');let owners={};
165
+ try{owners=JSON.parse(fs.readFileSync(file,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}
166
+ for(const [id,r] of Object.entries(owners))if(!sameProcess(r.owner,processes.get(r.owner.ProcessId)))delete owners[id];
167
+ if(owners[key])throw new Error('Run already has a live controller');
168
+ const claim={owner,token:crypto.randomUUID()};owners[key]=claim;atomicJson(file,owners);return claim;
169
+ });
170
+ }
171
+ export function releaseRun(dir,key,claim) {
172
+ return transaction(dir,()=>{
173
+ const file=path.join(dir,'run-owners.json');const owners=JSON.parse(fs.readFileSync(file,'utf8'));
174
+ if(owners[key]?.token===claim.token){delete owners[key];atomicJson(file,owners);}
175
+ });
176
+ }
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // 维护锁:编排器在对自身做升级/优化/维护/更新等改动期间上锁。上锁期间任何 agent
6
+ // 调用编排器(--run-windows/--spawn/--resume-run/preflight/force --assert 等动作入口)
7
+ // 都只得到 MAINTENANCE_MESSAGE 一句话——本轮跳过 orchestrator、由发起对话直接执行。
8
+ // 锁是礼让性门禁而非安全边界:文件损坏/过期一律 fail-open 视为未锁,绝不把工具卡死。
9
+ export const MAINTENANCE_MESSAGE = 'open-tui-orchestrator正在维护更新,本轮跳过orchestrator直接开始执行';
10
+ export const DEFAULT_TTL_MINUTES = 180;
11
+ export const MAX_TTL_MINUTES = 24 * 60;
12
+
13
+ // 锁全局唯一(按用户),与工作区无关:任何工作区里的调用都能看到同一把锁。
14
+ // ORCH_MAINTENANCE_LOCK 覆盖默认路径(测试/隔离用)。
15
+ export function maintenanceLockPath(env = process.env) {
16
+ const override = String(env.ORCH_MAINTENANCE_LOCK || '').trim();
17
+ if (override) return path.resolve(override);
18
+ return path.join(os.homedir(), '.open-tui-orchestrator', 'maintenance-lock.json');
19
+ }
20
+
21
+ function parseLockJson(raw) {
22
+ const text = String(raw);
23
+ return JSON.parse(text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text);
24
+ }
25
+
26
+ export function readMaintenanceLock(env = process.env, now = Date.now()) {
27
+ const file = maintenanceLockPath(env);
28
+ let raw;
29
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { locked: false }; }
30
+ let data;
31
+ try { data = parseLockJson(raw); } catch { return { locked: false, invalid: true }; }
32
+ const expiresAt = Number(data && data.expiresAt);
33
+ if (!Number.isFinite(expiresAt)) {
34
+ // expiresAt 缺失/非数字 = 损坏锁:按自身契约 fail-open(绝不把工具卡死),顺手清理。
35
+ try { fs.rmSync(file, { force: true }); } catch { /* ignore */ }
36
+ return { locked: false, invalid: true };
37
+ }
38
+ if (expiresAt <= now) {
39
+ // 过期即解锁并顺手清理(维护方崩溃/忘记解锁也不会把编排器关死)。
40
+ try { fs.rmSync(file, { force: true }); } catch { /* ignore */ }
41
+ return { locked: false, expired: true };
42
+ }
43
+ return {
44
+ locked: true,
45
+ reason: String((data && data.reason) || ''),
46
+ startedAt: Number(data && data.startedAt) || null,
47
+ expiresAt,
48
+ };
49
+ }
50
+
51
+ export function lockedForMaintenance(env = process.env) {
52
+ try { return readMaintenanceLock(env).locked; } catch { return false; }
53
+ }
54
+
55
+ export function acquireMaintenanceLock({ reason = '', ttlMinutes = DEFAULT_TTL_MINUTES, env = process.env } = {}) {
56
+ const ttl = Number(ttlMinutes);
57
+ const minutes = Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, MAX_TTL_MINUTES) : DEFAULT_TTL_MINUTES;
58
+ const file = maintenanceLockPath(env);
59
+ fs.mkdirSync(path.dirname(file), { recursive: true });
60
+ const now = Date.now();
61
+ const data = { version: 1, pid: process.pid, reason: String(reason || ''), startedAt: now, expiresAt: now + Math.round(minutes * 60000) };
62
+ // tmp+rename 原子落盘,读者永远只看到完整 JSON 或不存在。
63
+ const tmp = file + '.tmp-' + process.pid;
64
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
65
+ fs.renameSync(tmp, file);
66
+ return data;
67
+ }
68
+
69
+ export function releaseMaintenanceLock(env = process.env) {
70
+ try {
71
+ const file = maintenanceLockPath(env);
72
+ fs.rmSync(file, { force: true });
73
+ // 顺手收掉空目录(仅当为空才成功,绝不误删其它内容)。
74
+ try { fs.rmdirSync(path.dirname(file)); } catch { /* ignore */ }
75
+ return true;
76
+ } catch { return false; }
77
+ }
@@ -0,0 +1,9 @@
1
+ // Windows PowerShell 5 uses legacy native argv serialization. Preserve embedded
2
+ // quotes and trailing backslashes in quoted arguments; PowerShell 7 uses Standard.
3
+ export function nativeArgumentLines() {
4
+ return [
5
+ "if($PSVersionTable.PSVersion.Major -ge 7){$PSNativeCommandArgumentPassing='Standard'}else{",
6
+ " $agentArgs=@($agentArgs|ForEach-Object{$v=[regex]::Replace($_,'(\\\\*)\"','${1}${1}\\\"'); if($_ -match '\\s'){$v=[regex]::Replace($v,'\\\\+$','$0$0')}; $v})",
7
+ "}",
8
+ ];
9
+ }
@@ -0,0 +1,18 @@
1
+ // OpenAI-authenticated Responses over HTTPS, without the failing WebSocket probe.
2
+ // Keep the configured model and login. No third-party endpoint or credentials.
3
+ export const networkConfig = Object.freeze({
4
+ model_provider: 'orchestrator-openai-https',
5
+ model_providers: {
6
+ 'orchestrator-openai-https': {
7
+ name: 'OpenAI', wire_api: 'responses', requires_openai_auth: true,
8
+ supports_websockets: false, supports_standalone_web_search: true,
9
+ },
10
+ },
11
+ });
12
+
13
+ export function networkArgs() {
14
+ const provider = networkConfig.model_providers[networkConfig.model_provider];
15
+ return ['-c', `model_provider=${JSON.stringify(networkConfig.model_provider)}`,
16
+ ...Object.entries(provider).flatMap(([key, value]) =>
17
+ ['-c', `model_providers.${networkConfig.model_provider}.${key}=${JSON.stringify(value)}`])];
18
+ }
@@ -0,0 +1,25 @@
1
+ // Loaded before the actual installed CLI. Credentials stay in memory.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {pathToFileURL} from 'node:url';
5
+ import {isolatedConfig,protectSecrets} from './openclaw-config.mjs';
6
+ // Node on Windows can reject recursive mkdir of an existing drive root.
7
+ // Only an already-existing root directory qualifies; all other errors propagate.
8
+ if(process.platform==='win32'){
9
+ const mkdir=fs.promises.mkdir.bind(fs.promises);
10
+ fs.promises.mkdir=async function(target,options){
11
+ if(typeof target==='string'&&options?.recursive&&path.resolve(target)===path.parse(path.resolve(target)).root&&fs.statSync(target).isDirectory())return undefined;
12
+ return mkdir(target,options);
13
+ };
14
+ }
15
+ const spec=JSON.parse(fs.readFileSync(process.env.ORCH_OPENCLAW_SPEC,'utf8'));
16
+ const runtime=await import(pathToFileURL(path.join(spec.packageRoot,'dist','plugin-sdk','config-runtime.js')));
17
+ const source=runtime.loadConfig();
18
+ process.env.OPENCLAW_STATE_DIR=spec.state;
19
+ process.env.OPENCLAW_CONFIG_PATH=path.join(spec.state,'runtime-config.json');
20
+ process.env.TEMP=spec.state;process.env.TMP=spec.state;process.env.TMPDIR=spec.state;
21
+ runtime.clearConfigCache();
22
+ const cfg=isolatedConfig(source,spec.workspace,spec.state,spec.planner);
23
+ fs.writeFileSync(process.env.OPENCLAW_CONFIG_PATH,JSON.stringify(protectSecrets(cfg,process.env)),{encoding:'utf8',mode:0o600});
24
+ runtime.setRuntimeConfigSnapshot(cfg,cfg);
25
+ process.chdir(spec.workspace);
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ export function isolatedConfig(source,workspace,state,planner=false) {
3
+ const cfg=structuredClone(source);
4
+ cfg.agents={...cfg.agents,defaults:{...cfg.agents?.defaults,workspace,skipBootstrap:true,heartbeat:{every:'0m'}},entries:{main:{workspace,agentDir:path.join(state,'agents','main','agent')}}};
5
+ delete cfg.agents.list;delete cfg.agents.defaults.memorySearch;
6
+ cfg.memory={...cfg.memory,search:{enabled:false}};
7
+ cfg.channels={};cfg.hooks={enabled:false};cfg.cron={enabled:false};
8
+ // Channel plugins can discover saved accounts even without a channels entry.
9
+ // Explicitly disable Weixin in workers while retaining model provider plugins.
10
+ cfg.plugins={...cfg.plugins,entries:{...cfg.plugins?.entries,'openclaw-weixin':{enabled:false}}};
11
+ cfg.logging={...cfg.logging,file:path.join(state,'openclaw.log')};
12
+ cfg.tools={...cfg.tools,...(planner?{deny:['*']}:{})};
13
+ return cfg;
14
+ }
15
+ export function completionText(events,token) {
16
+ const last=events.filter(e=>e.type==='message'&&e.message).at(-1)?.message;
17
+ if(last?.role!=='assistant'||!['stop','end_turn'].includes(last.stopReason))return null;
18
+ const content=Array.isArray(last.content)?last.content:[{type:'text',text:last.content||''}];
19
+ if(content.some(c=>['toolCall','tool_use'].includes(c.type)))return null;
20
+ const text=content.filter(c=>c.type==='text').map(c=>c.text).join('').trim();
21
+ const lastLine=text.split(/\r?\n/).at(-1).trim();
22
+ return lastLine===`__ORCH_DONE__ ${token}`?lastLine:null;
23
+ }
24
+ export function protectSecrets(config,env) {
25
+ let index=0;
26
+ const visit=(value,key='')=>{
27
+ if(typeof value==='string'&&/(api.?key|token|password|secret|authorization|private.?key)/i.test(key)&&value&&!value.startsWith('${')){
28
+ const name='ORCH_OC_SECRET_'+index++;env[name]=value;return '${'+name+'}';
29
+ }
30
+ if(Array.isArray(value))return value.map(v=>visit(v,key));
31
+ if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,visit(v,k)]));
32
+ return value;
33
+ };
34
+ return visit(config);
35
+ }
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {probeCli} from './host-probe.mjs';
4
+ export function resolveOpenClawHost(env=process.env) {
5
+ const explicit=env.ORCH_CLI||env.OPENCLAW_EXE;
6
+ const paths=explicit?[path.resolve(explicit)]:String(env.PATH||env.Path||'').split(path.delimiter).flatMap(d=>['openclaw.cmd','openclaw'].map(n=>path.join(d,n)));
7
+ const rejected=[];
8
+ for(const file of [...new Set(paths)]){
9
+ if(!fs.existsSync(file))continue;
10
+ let entry=/\.mjs$/.test(file)?file:null;
11
+ if(!entry&&/node_modules[\\/]openclaw[\\/]openclaw\.mjs/.test(fs.readFileSync(file,'utf8')))entry=path.join(path.dirname(file),'node_modules','openclaw','openclaw.mjs');
12
+ if(!entry||!fs.existsSync(entry))continue;
13
+ const packageRoot=path.dirname(entry),pkg=JSON.parse(fs.readFileSync(path.join(packageRoot,'package.json'),'utf8'));
14
+ const bin=fs.existsSync(path.join(path.dirname(file),'node.exe'))?path.join(path.dirname(file),'node.exe'):process.execPath;
15
+ const ptyEntry=path.join(packageRoot,'node_modules','@lydell','node-pty','index.js');
16
+ // 接口自证先于版本门:版本常量只记「最后验证于」,不是准入门槛;未验证版本必须带探针实测结果 fail-closed。
17
+ // 运行时快照与 transcript 模式逐版敏感:扩版 = 逐版实机验收(见 docs/openclaw-adapter.md)。
18
+ const help=probeCli(bin,[entry,'tui','--help'],{env,timeoutMs:30000});
19
+ const probeOk=help.status===0&&['--local','--session','--message'].every(x=>String(help.stdout||'').includes(x));
20
+ if(pkg.name!=='openclaw'||pkg.version!=='2026.9.2'){
21
+ if(pkg.name==='openclaw')throw new Error('OPENCLAW_VERSION_UNTESTED: installed '+pkg.version+' (tui interface probe '+(probeOk?'OK':'mismatch')+', node-pty '+(fs.existsSync(ptyEntry)?'present':'missing')+'); last fully verified 2026.9.2. Expansion requires per-version acceptance of the config/transcript/TUI interfaces (docs/openclaw-adapter.md).');
22
+ throw new Error('OPENCLAW_VERSION_UNTESTED: supported version is 2026.9.2');
23
+ }
24
+ if(!probeOk){rejected.push(`${bin} ${entry}: tui --help ${help.error?'probe error '+help.error:'exit '+help.status+'; flags mismatch'}`);continue;}
25
+ if(!fs.existsSync(ptyEntry))throw new Error('OPENCLAW_PTY_MISSING');
26
+ return {agent:'openclaw',config:{id:'openclaw',bin,entry,packageRoot,ptyEntry,version:pkg.version,available:true}};
27
+ }
28
+ throw new Error(`CLI_MISSING: no supported installed OpenClaw CLI; no fallback or automatic install. Tried: ${rejected.join(' | ')||'no candidate found on disk'}`);
29
+ }
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {spawn} from 'node:child_process';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {nativeArgumentLines} from './native-argv.mjs';
6
+ const quote=x=>"'"+String(x).replaceAll("'","''")+"'";
7
+ export function writeOpenClawLauncher(config,{key,prompt,suffix,workspace,temp}) {
8
+ const stem=String(key+'-'+suffix).replace(/[^a-zA-Z0-9_-]/g,'-');
9
+ const token=/【本块唯一标识】\s*([A-Za-z0-9][A-Za-z0-9_-]*)/.exec(prompt)?.[1];
10
+ if(!token)throw new Error('OpenClaw window requires a completion token');
11
+ const state=path.join(temp,'orchestrator','openclaw',stem);fs.mkdirSync(state,{recursive:true});
12
+ const lp=path.join(temp,'win-launch-'+stem+'.ps1'),pidf=path.join(temp,'window-'+stem+'.pid'),pf=path.join(temp,'agent-win-'+stem+'.md'),rf=path.join(temp,'win-'+stem+'.result.json'),specFile=path.join(state,'launch.json');
13
+ fs.writeFileSync(pf,prompt,'utf8');
14
+ fs.writeFileSync(specFile,JSON.stringify({...config,state,workspace,prompt:pf,result:rf,token,timeoutMs:Number(process.env.ORCH_MAX_POLL_MS||900000)}),'utf8');
15
+ const args=[fileURLToPath(new URL('./openclaw-window.mjs',import.meta.url)),specFile];
16
+ fs.writeFileSync(lp,'\ufeff'+["$ErrorActionPreference = 'Stop'",`$PID | Set-Content -LiteralPath ${quote(pidf)} -Encoding UTF8`,
17
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8','$OutputEncoding = [System.Text.Encoding]::UTF8','chcp 65001 | Out-Null',`Set-Location -LiteralPath ${quote(workspace)}`,"$env:ORCH_WINDOW = '1'",
18
+ '$agentArgs = @('+args.map(quote).join(',')+')',...nativeArgumentLines(),`& ${quote(config.bin)} @agentArgs`,
19
+ `if (-not (Test-Path -LiteralPath ${quote(rf)})) { @{__EXIT__=1;__DONE__=$true;token=${quote(token)}} | ConvertTo-Json -Compress | Set-Content -LiteralPath ${quote(rf)} -Encoding UTF8 }`,'exit 0'].join('\r\n'),'utf8');
20
+ return {lp,pidf,pf,rf,openclawState:state};
21
+ }
22
+ export async function runOpenClawPlanner(config,prompt,cwd) {
23
+ const root=path.join(cwd,'temp','orchestrator','openclaw');fs.mkdirSync(root,{recursive:true});
24
+ const state=fs.mkdtempSync(path.join(root,'planner-')),specFile=path.join(state,'launch.json');
25
+ fs.writeFileSync(specFile,JSON.stringify({...config,state,workspace:cwd,planner:true}),'utf8');
26
+ try{return await new Promise((resolve,reject)=>{
27
+ const child=spawn(config.bin,['--import',new URL('./openclaw-bootstrap.mjs',import.meta.url).href,config.entry,'agent','exec','--cwd',cwd,'--state-dir',state,'--timeout','60','--json',prompt],{cwd,windowsHide:true,env:{...process.env,ORCH_OPENCLAW_SPEC:specFile,NODE_DISABLE_COMPILE_CACHE:'1'},stdio:['ignore','pipe','pipe']});
28
+ let raw='',timedOut=false;const timer=setTimeout(()=>{timedOut=true;child.kill();},75000);
29
+ child.stdout.on('data',b=>{raw+=b;if(raw.length>8000000)child.kill();});child.stderr.resume();
30
+ child.on('error',e=>{clearTimeout(timer);reject(e);});
31
+ child.on('close',code=>{clearTimeout(timer);try{if(code!==0||timedOut)throw new Error('OPENCLAW_PLANNER_FAILED');const value=JSON.parse(raw);const final=value.outputText||value.text||value.result?.finalAssistantVisibleText||value.result?.payloads?.map(p=>p.text||'').join('')||value.payloads?.map(p=>p.text||'').join('');if(!final)throw new Error('OPENCLAW_PLANNER_EMPTY_RESPONSE');resolve({final});}catch(e){reject(e);}});
32
+ });}finally{fs.rmSync(state,{recursive:true,force:true});}
33
+ }
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createRequire} from 'node:module';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {DatabaseSync} from 'node:sqlite';
6
+ import {spawnSync} from 'node:child_process';
7
+ import {completionText} from './openclaw-config.mjs';
8
+ const specFile=process.argv[2],spec=JSON.parse(fs.readFileSync(specFile,'utf8'));
9
+ const pty=createRequire(import.meta.url)(spec.ptyEntry);
10
+ const env={...process.env,ORCH_OPENCLAW_SPEC:specFile,NODE_DISABLE_COMPILE_CACHE:'1',OPENCLAW_NO_RESPAWN:'1'};
11
+ delete env.NODE_COMPILE_CACHE;
12
+ const validation=spawnSync(spec.bin,['--import',new URL('./openclaw-bootstrap.mjs',import.meta.url).href,spec.entry,'config','validate','--json'],{env,cwd:spec.workspace,windowsHide:true,timeout:30000,encoding:'utf8'});
13
+ if(validation.status!==0){
14
+ fs.writeFileSync(spec.result,JSON.stringify({__EXIT__:1,__DONE__:true,__REPORT__:'OPENCLAW_CONFIG_INVALID: preflight failed before interactive startup',token:spec.token}),'utf8');
15
+ process.exit(1);
16
+ }
17
+ const child=pty.spawn(spec.bin,['--import',new URL('./openclaw-bootstrap.mjs',import.meta.url).href,spec.entry,'tui','--local','--session','main','--message',fs.readFileSync(spec.prompt,'utf8'),'--timeout-ms',String(spec.timeoutMs)],{cwd:spec.workspace,env,cols:process.stdout.columns||120,rows:process.stdout.rows||35,name:'xterm-256color'});
18
+ child.onData(data=>process.stdout.write(data));
19
+ let marker=null,seenAt=0,closingAt=0,failure='',finished=false;
20
+ const dbFile=path.join(spec.state,'agents','main','agent','openclaw-agent.sqlite');
21
+ const started=Date.now();
22
+ const timer=setInterval(()=>{
23
+ if(Date.now()-started>spec.timeoutMs&&!closingAt){failure='OPENCLAW_WINDOW_TIMEOUT';closingAt=Date.now();child.write('/exit\r');}
24
+ if(closingAt){if(Date.now()-closingAt>10000)child.kill();return;}
25
+ if(!fs.existsSync(dbFile))return;
26
+ let db;
27
+ try{
28
+ db=new DatabaseSync(dbFile,{readOnly:true});
29
+ const rows=db.prepare('SELECT e.event_json FROM transcript_events e JOIN session_nodes s ON s.current_session_id=e.session_id WHERE s.session_key=? ORDER BY e.seq DESC LIMIT 8').all('agent:main:main');
30
+ const text=completionText(rows.reverse().map(r=>JSON.parse(r.event_json)),spec.token);
31
+ if(!text){seenAt=0;return;}
32
+ if(!seenAt){seenAt=Date.now();return;}
33
+ if(Date.now()-seenAt>=1000){marker=text;closingAt=Date.now();child.write('/exit\r');}
34
+ }catch(error){if(!/locked|busy|no such table/i.test(error.message)){failure='OPENCLAW_TRANSCRIPT_UNSUPPORTED: '+error.message;closingAt=Date.now();child.write('/exit\r');}}
35
+ finally{db?.close();}
36
+ },500);
37
+ child.onExit(({exitCode})=>{
38
+ if(finished)return;finished=true;clearInterval(timer);
39
+ const result={__EXIT__:marker&&!failure&&exitCode===0?0:1,__DONE__:true,__REPORT__:marker||failure||'OpenClaw exited without a verified completion marker',token:spec.token};
40
+ fs.writeFileSync(path.join(spec.state,'completion.json'),JSON.stringify({...result,nativeExitCode:exitCode,automaticClose:Boolean(marker)&&!failure,finishedAt:Date.now()}),'utf8');
41
+ fs.writeFileSync(spec.result+'.tmp',JSON.stringify(result),'utf8');fs.renameSync(spec.result+'.tmp',spec.result);
42
+ process.exit(result.__EXIT__);
43
+ });
44
+ process.on('SIGINT',()=>{failure='OPENCLAW_INTERRUPTED';closingAt=Date.now();child.write('\x03');});
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {probeCli} from './host-probe.mjs';
4
+
5
+ // opencode adapter: resolve the real opencode binary and build window/planner argv.
6
+ // Window mode is interactive `opencode run -i --auto` (auto-approves permissions like
7
+ // pi's --approve; the orchestrator has already normalized authorization).
8
+ export function resolveOpenCodeHost(env = process.env) {
9
+ const explicit = env.ORCH_CLI || env.OPENCODE_EXE;
10
+ const candidates = explicit
11
+ ? [path.resolve(explicit)]
12
+ : String(env.PATH || env.Path || '')
13
+ .split(path.delimiter)
14
+ .flatMap((dir) => ['opencode.exe', 'opencode.cmd', 'opencode'].map((n) => path.join(dir, n)));
15
+ const rejected = [];
16
+ for (const file of [...new Set(candidates)]) {
17
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue;
18
+ let bin = file;
19
+ let prefixArgs = [];
20
+ if (!/\.exe$/i.test(file)) {
21
+ // npm shim (opencode / opencode.cmd) -> resolve the packaged opencode-ai binary.
22
+ let entry = /\.[cm]?js$/i.test(file) ? file : null;
23
+ if (!entry) {
24
+ try {
25
+ const shim = fs.readFileSync(file, 'utf8');
26
+ const match = shim.match(/node_modules[\\/]opencode-ai[\\/]bin[\\/](opencode\.exe|opencode)/);
27
+ if (match) entry = path.join(path.dirname(file), 'node_modules', 'opencode-ai', 'bin', match[1]);
28
+ } catch {
29
+ /* not a text shim */
30
+ }
31
+ }
32
+ const packaged = path.join(path.dirname(file), 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
33
+ entry = [entry, packaged].find((p) => p && fs.existsSync(p));
34
+ if (!entry) continue;
35
+ if (/\.exe$/i.test(entry)) {
36
+ bin = entry;
37
+ } else {
38
+ const sibling = path.join(path.dirname(file), 'node.exe');
39
+ bin = fs.existsSync(sibling) ? sibling : process.execPath;
40
+ prefixArgs = [entry];
41
+ }
42
+ }
43
+ const label = [bin, ...prefixArgs].join(' ');
44
+ const help = probeCli(bin, [...prefixArgs, '--help'], {env});
45
+ if (help.status !== 0 || !/run opencode with a message/.test(help.out)) { rejected.push(`${label}: --help ${help.error ? 'probe error ' + help.error : 'exit ' + help.status + '; banner mismatch'}`); continue; }
46
+ const runHelp = probeCli(bin, [...prefixArgs, 'run', '--help'], {env});
47
+ if (runHelp.status !== 0 || !['--interactive', '--auto', '--dir'].every((f) => runHelp.out.includes(f))) { rejected.push(`${label}: run --help ${runHelp.error ? 'probe error ' + runHelp.error : 'exit ' + runHelp.status + '; flags mismatch'}`); continue; }
48
+ const versionResult = probeCli(bin, [...prefixArgs, '--version'], {env});
49
+ const version = versionResult.out.trim().split(/\s+/)[0];
50
+ if (versionResult.status !== 0 || !version) { rejected.push(`${label}: --version ${versionResult.error ? 'probe error ' + versionResult.error : 'exit ' + versionResult.status + '; unusable output'}`); continue; }
51
+ return {agent:'opencode', config:{id:'opencode', label:'opencode', version, bin, prefixArgs, available:true, native:prefixArgs.length===0, completionMarker:'__ORCH_DONE__', modelEnv:'ORCH_MODEL', effortEnv:'ORCH_EFFORT'}};
52
+ }
53
+ throw new Error(`CLI_MISSING: opencode; no supported installed opencode CLI found. No fallback or automatic install. Tried: ${rejected.join(' | ') || 'no candidate found on disk'}`);
54
+ }
55
+
56
+ // Base argv for an opencode run. The interactive window uses `run -i --auto --dir <cwd>`.
57
+ export function opencodeArgs(config, {model, effort, cwd} = {}) {
58
+ return [
59
+ ...(config.prefixArgs || []),
60
+ 'run',
61
+ '-i',
62
+ '--auto',
63
+ '--dir',
64
+ cwd || process.cwd(),
65
+ ...(model && String(model).trim() && !/\s/.test(String(model)) ? ['--model', String(model).trim()] : []),
66
+ ];
67
+ }
68
+
69
+ // Planner argv: headless, JSON events, no interactive mode.
70
+ export function opencodePlannerArgs(config, {model, cwd} = {}) {
71
+ return [
72
+ ...(config.prefixArgs || []),
73
+ 'run',
74
+ '--format',
75
+ 'json',
76
+ '--dir',
77
+ cwd || process.cwd(),
78
+ ...(model && String(model).trim() && !/\s/.test(String(model)) ? ['--model', String(model).trim()] : []),
79
+ ];
80
+ }