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.
- package/CHANGELOG.md +205 -0
- package/INSTALL-zh.md +96 -0
- package/INSTALL.md +96 -0
- package/LICENSE +48 -0
- package/README-zh.md +181 -0
- package/README.md +181 -0
- package/cli.mjs +37 -0
- package/docs/adapt.md +103 -0
- package/docs/assets/kimicode-agent-swarm-10-subagents.png +0 -0
- package/docs/auto-recovery.md +23 -0
- package/docs/caller-driven.md +121 -0
- package/docs/claude-adapter.md +25 -0
- package/docs/execution-contract.md +70 -0
- package/docs/inactive-windows.md +11 -0
- package/docs/kimi-adapter.md +27 -0
- package/docs/kimi-integration.md +56 -0
- package/docs/maintenance-lock.md +32 -0
- package/docs/openclaw-adapter.md +59 -0
- package/docs/openclaw-assessment-2026-09-06.md +59 -0
- package/docs/opencode-adapter.md +25 -0
- package/docs/pi-adapter.md +58 -0
- package/docs/public-readiness.md +63 -0
- package/docs/release-policy.md +39 -0
- package/docs/security-audit-2026-09-09.md +41 -0
- package/docs/trust-and-safety.md +64 -0
- package/docs/verification-2026-09-06.md +22 -0
- package/docs/verification-recovery-2026-09-06.md +36 -0
- package/orch.mjs +20 -0
- package/package.json +36 -0
- package/release.json +116 -0
- package/repair.mjs +228 -0
- package/scripts/adapt.mjs +35 -0
- package/scripts/agent-auth-prompt.txt +10 -0
- package/scripts/agent.mjs +1 -0
- package/scripts/core/adapt-lib.mjs +219 -0
- package/scripts/core/agent-auth-prompt.txt +10 -0
- package/scripts/core/agent-profiles/hermes.json +59 -0
- package/scripts/core/agent.mjs +1 -0
- package/scripts/core/checkpoint.mjs +38 -0
- package/scripts/core/claude-host.mjs +50 -0
- package/scripts/core/claude-runtime.mjs +111 -0
- package/scripts/core/contracts.mjs +161 -0
- package/scripts/core/host-cli.mjs +204 -0
- package/scripts/core/host-model.mjs +323 -0
- package/scripts/core/host-probe.mjs +16 -0
- package/scripts/core/inactive-window.mjs +32 -0
- package/scripts/core/inactive-window.ps1 +36 -0
- package/scripts/core/kimi-host.mjs +41 -0
- package/scripts/core/kimi-runtime.mjs +140 -0
- package/scripts/core/lease-lock.ps1 +32 -0
- package/scripts/core/leases.mjs +176 -0
- package/scripts/core/maintenance-lock.mjs +77 -0
- package/scripts/core/native-argv.mjs +9 -0
- package/scripts/core/network-policy.mjs +18 -0
- package/scripts/core/openclaw-bootstrap.mjs +25 -0
- package/scripts/core/openclaw-config.mjs +35 -0
- package/scripts/core/openclaw-host.mjs +29 -0
- package/scripts/core/openclaw-runtime.mjs +33 -0
- package/scripts/core/openclaw-window.mjs +44 -0
- package/scripts/core/opencode-host.mjs +80 -0
- package/scripts/core/opencode-runtime.mjs +131 -0
- package/scripts/core/orchestrate-sdk.mjs +2595 -0
- package/scripts/core/pi-host.mjs +29 -0
- package/scripts/core/pi-runtime.mjs +54 -0
- package/scripts/core/pi-shutdown.mjs +16 -0
- package/scripts/core/poll-windows.mjs +48 -0
- package/scripts/core/print-profile.mjs +79 -0
- package/scripts/core/print-runtime.mjs +106 -0
- package/scripts/core/pty-host.mjs +38 -0
- package/scripts/core/recovery.mjs +75 -0
- package/scripts/core/run-board.mjs +155 -0
- package/scripts/core/run-guardian.mjs +130 -0
- package/scripts/core/runner.mjs +274 -0
- package/scripts/core/runtime-context.mjs +23 -0
- package/scripts/core/unit-carrier.mjs +55 -0
- package/scripts/core/unit-command.mjs +96 -0
- package/scripts/core/unit-runtime.mjs +107 -0
- package/scripts/gate.mjs +162 -0
- package/scripts/host-cli.mjs +2 -0
- package/scripts/install-deps.mjs +58 -0
- package/scripts/maintenance-lock.mjs +46 -0
- package/scripts/network-policy.mjs +2 -0
- package/scripts/open-tui-orchestrator-force.mjs +239 -0
- package/scripts/open-tui-orchestrator-preflight.mjs +85 -0
- package/scripts/orchestrate-sdk.mjs +59 -0
- package/scripts/package-lock.json +242 -0
- package/scripts/package.json +9 -0
- package/scripts/platform-guard.mjs +23 -0
- package/scripts/poll-windows.mjs +8 -0
- package/scripts/release-integrity.mjs +94 -0
- package/scripts/runtime-context.mjs +2 -0
- package/scripts/sdk-dependency-check.mjs +32 -0
- package/scripts/todo-list.mjs +89 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// 发起窗 model/thinking level 继承:检测「发起本 orchestrator 的那个 TUI 窗口」当前实际使用的
|
|
2
|
+
// model 与 thinking effort,让发起的子窗与发起窗保持一致(宿主自适应的延伸:同窗即同 agent,
|
|
3
|
+
// 取值逐字透传,无需跨 agent 映射)。
|
|
4
|
+
//
|
|
5
|
+
// 检测数据源(全部本地只读,任何一步失败都降级而非抛错):
|
|
6
|
+
// codex — CODEX_THREAD_ID 定位当日 rollout jsonl,取最后出现的 "model"/"reasoning_effort";
|
|
7
|
+
// 回退 ~/.codex/config.toml 的 model / model_reasoning_effort。
|
|
8
|
+
// kimi — ~/.kimi-code/sessions/<wd_*>/session_*/state.json 按 cwd 匹配、updatedAt 最新者
|
|
9
|
+
// 认定为发起会话,读其 agents/main/wire.jsonl 尾部最后一条 llm.request 的
|
|
10
|
+
// modelAlias/thinkingEffort;回退 config.toml 的 default_model / [thinking].effort。
|
|
11
|
+
// pi — ~/.pi/agent/settings.json 的 defaultModel / defaultThinkingLevel。
|
|
12
|
+
// opencode — ~/.config/opencode/opencode.json(c) 顶层 model;opencode 无 effort 概念。
|
|
13
|
+
// openclaw — 配置世界隔离,返回 null(不动它)。
|
|
14
|
+
//
|
|
15
|
+
// 优先级:显式 ORCH_MODEL / ORCH_EFFORT 环境变量 > 本模块检测值 > CLI 自身默认。
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import {loadProfile} from './print-profile.mjs';
|
|
20
|
+
|
|
21
|
+
// 合法 model/effort 值绝不含空白与引号;脏值一律丢弃(下游会拼进 ps1/argv,防注入)。
|
|
22
|
+
function clean(v) {
|
|
23
|
+
const s = String(v ?? '').trim();
|
|
24
|
+
return s && !/[\s"']/.test(s) ? s : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readTailLines(file, bytes = 262144) {
|
|
28
|
+
let fd;
|
|
29
|
+
try {
|
|
30
|
+
const size = fs.statSync(file).size;
|
|
31
|
+
if (!size) return [];
|
|
32
|
+
fd = fs.openSync(file, 'r');
|
|
33
|
+
const start = Math.max(0, size - bytes);
|
|
34
|
+
const buf = Buffer.alloc(size - start);
|
|
35
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
36
|
+
return buf.toString('utf8').split(/\r?\n/);
|
|
37
|
+
} catch { return []; }
|
|
38
|
+
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 从后往前扫 JSONL 行,返回最后一条满足 test(line) 的可解析记录;末行可能正在写入,解析失败即跳过。
|
|
42
|
+
function lastJsonlRecord(lines, test) {
|
|
43
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
if (!line || !test(line)) continue;
|
|
46
|
+
try { return JSON.parse(line); } catch { /* 半行/坏行跳过 */ }
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lastMatch(text, re) {
|
|
52
|
+
let m, last = null;
|
|
53
|
+
while ((m = re.exec(text)) !== null) last = m[1];
|
|
54
|
+
return clean(last);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseTomlScalar(text, key) {
|
|
58
|
+
const m = new RegExp('^' + key + '\\s*=\\s*"([^"]+)"', 'm').exec(text);
|
|
59
|
+
return clean(m && m[1]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------- codex ----
|
|
63
|
+
function codexRolloutFile(sessionsDir, threadId) {
|
|
64
|
+
// sessions/<YYYY>/<MM>/<DD>/rollout-*<threadId>.jsonl;目录名可字典序倒排,发起会话必在最近几天。
|
|
65
|
+
let years = [];
|
|
66
|
+
try { years = fs.readdirSync(sessionsDir).filter((d) => /^\d{4}$/.test(d)).sort().reverse(); } catch { return null; }
|
|
67
|
+
outer: for (const y of years.slice(0, 1)) {
|
|
68
|
+
let months = [];
|
|
69
|
+
try { months = fs.readdirSync(path.join(sessionsDir, y)).filter((d) => /^\d{2}$/.test(d)).sort().reverse(); } catch { continue; }
|
|
70
|
+
for (const mo of months.slice(0, 2)) {
|
|
71
|
+
let days = [];
|
|
72
|
+
try { days = fs.readdirSync(path.join(sessionsDir, y, mo)).filter((d) => /^\d{2}$/.test(d)).sort().reverse(); } catch { continue; }
|
|
73
|
+
for (const d of days.slice(0, 4)) {
|
|
74
|
+
const dir = path.join(sessionsDir, y, mo, d);
|
|
75
|
+
let files = [];
|
|
76
|
+
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
77
|
+
const hit = files.find((f) => f.includes(threadId) && f.endsWith('.jsonl'));
|
|
78
|
+
if (hit) return path.join(dir, hit);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
break outer; // 只查最近一个年份目录
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function detectCodex({ env, codexHome }) {
|
|
87
|
+
const home = codexHome || env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
88
|
+
const tid = clean(env.CODEX_THREAD_ID) || clean(env.CODEX_SESSION_ID);
|
|
89
|
+
let model = null, effort = null, source = null;
|
|
90
|
+
if (tid) {
|
|
91
|
+
const file = codexRolloutFile(path.join(home, 'sessions'), tid);
|
|
92
|
+
if (file) {
|
|
93
|
+
const tail = readTailLines(file).join('\n');
|
|
94
|
+
model = lastMatch(tail, /"model":"([^"]+)"/g);
|
|
95
|
+
effort = lastMatch(tail, /"reasoning_effort":"([^"]+)"/g);
|
|
96
|
+
if (model || effort) source = 'codex-rollout';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!model || !effort) {
|
|
100
|
+
try {
|
|
101
|
+
const toml = fs.readFileSync(path.join(home, 'config.toml'), 'utf8');
|
|
102
|
+
if (!model) model = parseTomlScalar(toml, 'model');
|
|
103
|
+
if (!effort) effort = parseTomlScalar(toml, 'model_reasoning_effort');
|
|
104
|
+
if ((model || effort) && !source) source = 'codex-config';
|
|
105
|
+
} catch { /* 无 config.toml 就算了 */ }
|
|
106
|
+
}
|
|
107
|
+
return model || effort ? { model, effort, source } : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ----------------------------------------------------------------- kimi ----
|
|
111
|
+
function kimiHostSession(sessionsRoot, cwd) {
|
|
112
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
113
|
+
const want = norm(cwd);
|
|
114
|
+
let best = null;
|
|
115
|
+
let wds = [];
|
|
116
|
+
try { wds = fs.readdirSync(sessionsRoot); } catch { return null; }
|
|
117
|
+
for (const wd of wds) {
|
|
118
|
+
const wdDir = path.join(sessionsRoot, wd);
|
|
119
|
+
let sessions = [];
|
|
120
|
+
try { sessions = fs.readdirSync(wdDir); } catch { continue; }
|
|
121
|
+
for (const s of sessions) {
|
|
122
|
+
const stateFile = path.join(wdDir, s, 'state.json');
|
|
123
|
+
try {
|
|
124
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
125
|
+
if (norm(state.cwd) !== want) continue;
|
|
126
|
+
const at = Number(state.updatedAt) || 0;
|
|
127
|
+
if (!best || at > best.at) best = { at, dir: path.join(wdDir, s) };
|
|
128
|
+
} catch { /* 坏 state.json 跳过 */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return best && best.dir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function detectKimi({ cwd, kimiHome, env = process.env }) {
|
|
135
|
+
const home = kimiHome || env.KIMI_CODE_HOME || path.join(os.homedir(), '.kimi-code');
|
|
136
|
+
let model = null, effort = null, source = null;
|
|
137
|
+
const sessionDir = cwd && kimiHostSession(path.join(home, 'sessions'), cwd);
|
|
138
|
+
if (sessionDir) {
|
|
139
|
+
const rec = lastJsonlRecord(
|
|
140
|
+
readTailLines(path.join(sessionDir, 'agents', 'main', 'wire.jsonl')),
|
|
141
|
+
(line) => line.includes('"llm.request"'),
|
|
142
|
+
);
|
|
143
|
+
if (rec) {
|
|
144
|
+
model = clean(rec.modelAlias) || clean(rec.model);
|
|
145
|
+
effort = clean(rec.thinkingEffort);
|
|
146
|
+
if (model || effort) source = 'kimi-wire';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!model || !effort) {
|
|
150
|
+
try {
|
|
151
|
+
const toml = fs.readFileSync(path.join(home, 'config.toml'), 'utf8');
|
|
152
|
+
if (!model) model = parseTomlScalar(toml, 'default_model');
|
|
153
|
+
if (!effort) {
|
|
154
|
+
const sec = /\[thinking\]([^\[]*)/.exec(toml);
|
|
155
|
+
if (sec) effort = parseTomlScalar(sec[1], 'effort');
|
|
156
|
+
}
|
|
157
|
+
if ((model || effort) && !source) source = 'kimi-config';
|
|
158
|
+
} catch { /* ignore */ }
|
|
159
|
+
}
|
|
160
|
+
return model || effort ? { model, effort, source } : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ------------------------------------------------------------------- pi ----
|
|
164
|
+
function detectPi({ piHome }) {
|
|
165
|
+
const home = piHome || path.join(os.homedir(), '.pi');
|
|
166
|
+
try {
|
|
167
|
+
const s = JSON.parse(fs.readFileSync(path.join(home, 'agent', 'settings.json'), 'utf8'));
|
|
168
|
+
const model = clean(s.defaultModel);
|
|
169
|
+
const effort = clean(s.defaultThinkingLevel);
|
|
170
|
+
return model || effort ? { model, effort, source: 'pi-settings' } : null;
|
|
171
|
+
} catch { return null; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ------------------------------------------------------------- opencode ----
|
|
175
|
+
function parseJsonc(text) {
|
|
176
|
+
try { return JSON.parse(text); } catch { /* 继续走清洗 */ }
|
|
177
|
+
const stripped = String(text)
|
|
178
|
+
.split(/\r?\n/).filter((l) => !/^\s*\/\//.test(l)).join('\n')
|
|
179
|
+
.replace(/,\s*([}\]])/g, '$1');
|
|
180
|
+
try { return JSON.parse(stripped); } catch { return null; }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function detectOpenCode({ configHome }) {
|
|
184
|
+
const dir = configHome || path.join(os.homedir(), '.config', 'opencode');
|
|
185
|
+
for (const name of ['opencode.json', 'opencode.jsonc']) {
|
|
186
|
+
try {
|
|
187
|
+
const cfg = parseJsonc(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
188
|
+
const model = clean(cfg && cfg.model);
|
|
189
|
+
if (model) return { model, effort: null, source: 'opencode-config' };
|
|
190
|
+
} catch { /* 尝试下一个文件名 */ }
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --------------------------------------------------------------- claude ----
|
|
196
|
+
// Claude Code settings: ~/.claude/settings.json 顶层 model(别名或完整 id);
|
|
197
|
+
// effort 读取 settings 的 "effort"(存在时)。缺失即 null,绝不猜。
|
|
198
|
+
function detectClaude({ claudeHome, env = process.env }) {
|
|
199
|
+
const home = claudeHome || env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
200
|
+
for (const name of ['settings.json', path.join('..', '.claude.json')]) {
|
|
201
|
+
try {
|
|
202
|
+
const file = path.join(home, name);
|
|
203
|
+
if (!fs.existsSync(file)) continue;
|
|
204
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
205
|
+
const model = typeof cfg?.model === 'string' && cfg.model.trim() ? cfg.model.trim() : '';
|
|
206
|
+
const effort = typeof cfg?.effort === 'string' && cfg.effort.trim() ? cfg.effort.trim() : '';
|
|
207
|
+
if (model || effort) return { model: model || null, effort: effort || null, source: 'claude-settings' };
|
|
208
|
+
} catch { /* try next */ }
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --------------------------------------------------------------- profile ----
|
|
214
|
+
// print-class 画像 agent 的 model/effort 探测:由画像的可选 modelDetection 规格描述
|
|
215
|
+
// (adapt 自动做精确候选发现,也可手填)。规格形态:
|
|
216
|
+
// modelDetection: { env:["<ID>_MODEL"], config:[{file:"~/.x.conf.yml", modelKey:"model", effortKey:"effort"}] }
|
|
217
|
+
// 只读精确键名,绝不猜测;缺失即 null(回退该 CLI 自身默认,或用 ORCH_MODEL/ORCH_EFFORT 显式指定)。
|
|
218
|
+
export function readProfileKey(file, key, jsonc) {
|
|
219
|
+
let text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
|
|
220
|
+
if (/\.jsonc$/i.test(file) || jsonc) text = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
221
|
+
if (/\.jsonc?$/i.test(file) || jsonc) {
|
|
222
|
+
const value = JSON.parse(text)?.[key];
|
|
223
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
224
|
+
}
|
|
225
|
+
const re = new RegExp('^\\s*' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*:\\s*["\']?([^"\'#\\r\\n]+)', 'm');
|
|
226
|
+
const m = re.exec(text);
|
|
227
|
+
return m && m[1].trim() ? m[1].trim() : '';
|
|
228
|
+
}
|
|
229
|
+
// YAML 风格「节内键」读取:model: 节下的 default / reasoning_effort 等嵌套键(JSON 亦支持)。
|
|
230
|
+
// 只读精确节点名与精确键名,绝不猜测。
|
|
231
|
+
export function readSectionKey(file, section, key, jsonc) {
|
|
232
|
+
let text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
|
|
233
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
234
|
+
if (/\.jsonc?$/i.test(file) || jsonc) {
|
|
235
|
+
let obj = null;
|
|
236
|
+
try { obj = JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')); } catch { obj = null; }
|
|
237
|
+
const v = obj && obj[section] && obj[section][key];
|
|
238
|
+
return typeof v === 'string' && v.trim() ? v.trim() : '';
|
|
239
|
+
}
|
|
240
|
+
const lines = text.split(/\r?\n/);
|
|
241
|
+
const secRe = new RegExp('^(\\s*)' + esc(section) + '\\s*:\\s*$');
|
|
242
|
+
for (let i = 0; i < lines.length; i++) {
|
|
243
|
+
const m = secRe.exec(lines[i]);
|
|
244
|
+
if (!m) continue;
|
|
245
|
+
const indent = m[1].length;
|
|
246
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
247
|
+
const line = lines[j];
|
|
248
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
249
|
+
const ind = /^(\s*)/.exec(line)[1].length;
|
|
250
|
+
if (ind <= indent) break;
|
|
251
|
+
const hit = new RegExp('^\\s*' + esc(key) + '\\s*:\\s*["\']?([^"\'#\\r\\n]+)').exec(line);
|
|
252
|
+
if (hit && hit[1].trim()) return hit[1].trim();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return '';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 该 agent 是否具备「档位继承探测」能力(原生适配器或带 modelDetection 的画像)。
|
|
259
|
+
// 用于「无记录 → 宿主自身配置兜底」时打印明确声明(禁止静默换档);openclaw/纯自定义单元不适用。
|
|
260
|
+
export function hasModelDetector(agent, env = process.env) {
|
|
261
|
+
if (['codex', 'kimi', 'pi', 'opencode', 'claude'].includes(String(agent || ''))) return true;
|
|
262
|
+
try { return !!(loadProfile(agent, env) || {}).modelDetection; } catch { return false; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function detectProfile({ id, env = process.env }) {
|
|
266
|
+
const profile = loadProfile(id, env);
|
|
267
|
+
const md = profile && profile.modelDetection;
|
|
268
|
+
if (!md) return null;
|
|
269
|
+
let model = '', effort = '';
|
|
270
|
+
for (const name of md.env || []) { if (!model && env[name]) model = String(env[name]).trim(); }
|
|
271
|
+
for (const name of md.effortEnv || []) { if (!effort && env[name]) effort = String(env[name]).trim(); }
|
|
272
|
+
for (const c of md.config || []) {
|
|
273
|
+
try {
|
|
274
|
+
const home = env.USERPROFILE || env.HOME || os.homedir();
|
|
275
|
+
const raw = String(c.file || '');
|
|
276
|
+
// ~ 展开 + $VAR/${VAR} 环境变量展开(如 $HERMES_HOME/config.yaml);变量未定义时保持字面量、文件不存在即跳过。
|
|
277
|
+
const expanded = raw.startsWith('~') ? path.join(home, raw.slice(1))
|
|
278
|
+
: raw.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, name) => env[name] || m);
|
|
279
|
+
const file = path.resolve(expanded);
|
|
280
|
+
if (!expanded || !fs.existsSync(file)) continue;
|
|
281
|
+
const readIn = (sec, k) => (sec ? readSectionKey(file, sec, k) : readProfileKey(file, k));
|
|
282
|
+
if (!model && c.modelKey) model = readIn(c.section, c.modelKey);
|
|
283
|
+
if (!effort && c.effortKey) effort = readIn(c.effortSection || c.section, c.effortKey);
|
|
284
|
+
} catch { /* try next */ }
|
|
285
|
+
}
|
|
286
|
+
return model || effort ? { model: model || null, effort: effort || null, source: 'profile:' + id } : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --------------------------------------------------------------- 调度 ------
|
|
290
|
+
export function detectHostModelEffort(agent, { cwd, env = process.env, homes = {} } = {}) {
|
|
291
|
+
try {
|
|
292
|
+
switch (String(agent || '')) {
|
|
293
|
+
case 'codex': return detectCodex({ env, codexHome: homes.codex });
|
|
294
|
+
case 'kimi': return detectKimi({ cwd, kimiHome: homes.kimi, env });
|
|
295
|
+
case 'pi': return detectPi({ piHome: homes.pi });
|
|
296
|
+
case 'opencode': return detectOpenCode({ configHome: homes.opencode });
|
|
297
|
+
case 'claude': return detectClaude({ claudeHome: homes.claude, env });
|
|
298
|
+
default: {
|
|
299
|
+
const profiled = detectProfile({ id: String(agent || ''), env });
|
|
300
|
+
if (profiled) return profiled;
|
|
301
|
+
return null; // openclaw 与未知 agent:不检测
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch { return null; } // 检测绝不让引擎崩
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 引擎初始化时调用一次:把检测到的发起窗 model/effort 写回 env,下游所有适配器
|
|
308
|
+
// (codex -m/-c 注入、kimi -m、pi --model/--thinking、opencode --model)都消费
|
|
309
|
+
// ORCH_MODEL/ORCH_EFFORT,一次写回处处生效。显式 env 优先,只补空缺。
|
|
310
|
+
export function inheritHostModelEffort(agent, { cwd, env = process.env, homes = {} } = {}) {
|
|
311
|
+
if (env.NODE_TEST_CONTEXT) return null; // 测试确定性:node --test 下不碰真实宿主状态
|
|
312
|
+
if (env.ORCH_MODEL && env.ORCH_EFFORT) return null;
|
|
313
|
+
const detected = detectHostModelEffort(agent, { cwd, env, homes });
|
|
314
|
+
if (!detected) return null;
|
|
315
|
+
if (!env.ORCH_MODEL && detected.model) env.ORCH_MODEL = detected.model;
|
|
316
|
+
if (!env.ORCH_EFFORT && detected.effort) env.ORCH_EFFORT = detected.effort;
|
|
317
|
+
// kimi CLI 无 effort 旗标;官方按进程渠道是 KIMI_MODEL_THINKING_EFFORT(kimi provider 生效,
|
|
318
|
+
// 其它 provider 类型读取与否由 CLI 决定,不生效时窗回退 config.toml [thinking].effort)。
|
|
319
|
+
if (agent === 'kimi' && detected.effort && !env.KIMI_MODEL_THINKING_EFFORT) {
|
|
320
|
+
env.KIMI_MODEL_THINKING_EFFORT = detected.effort;
|
|
321
|
+
}
|
|
322
|
+
return detected;
|
|
323
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {spawnSync} from 'node:child_process';
|
|
2
|
+
// 主机 CLI 探测原语:冷启动/杀软扫描会偶发把单次 spawnSync 拖过 10s(实测后台引擎两次
|
|
3
|
+
// 在启动 ~11s 处因 --help 探测超时被误判 CLI_MISSING),因此对超时做有限重试;
|
|
4
|
+
// 确定性失败(非 0 退出、ENOENT 等)不重试。超时/次数可用 env 覆盖(测试与现场调优用)。
|
|
5
|
+
export function probeCli(bin,args,{env=process.env,timeoutMs,attempts}={}) {
|
|
6
|
+
const t=Number(timeoutMs??env.ORCH_HOST_PROBE_TIMEOUT_MS)||10000;
|
|
7
|
+
const n=Math.max(1,Math.floor(Number(attempts??env.ORCH_HOST_PROBE_ATTEMPTS)||2));
|
|
8
|
+
let last;
|
|
9
|
+
for(let i=0;i<n;i++){
|
|
10
|
+
const r=spawnSync(bin,args,{env,encoding:'utf8',windowsHide:true,shell:false,timeout:t});
|
|
11
|
+
last={status:r.status,error:r.error?String(r.error):null,stdout:String(r.stdout||''),stderr:String(r.stderr||''),out:String((r.stdout||'')+(r.stderr||''))};
|
|
12
|
+
if(last.status===0)return last;
|
|
13
|
+
if(!/ETIMEDOUT|timed out/i.test(last.error||''))return last;
|
|
14
|
+
}
|
|
15
|
+
return last;
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {powershellExe} from './contracts.mjs';
|
|
4
|
+
|
|
5
|
+
// 锚定 SystemRoot 的 powershell.exe:裸名会先按进程 cwd(任意工作区)解析,防二进制投毒。
|
|
6
|
+
const PS_EXE=powershellExe();
|
|
7
|
+
|
|
8
|
+
// Windows command-line quoting, including quotes and trailing backslashes.
|
|
9
|
+
export function quoteWindowsArgument(value){
|
|
10
|
+
const text=String(value);
|
|
11
|
+
if(text.includes('\0'))throw new Error('Invalid NUL in window argument');
|
|
12
|
+
return '"'+text.replace(/(\\*)"/g,'$1$1\\"').replace(/\\+$/g,'$&$&')+'"';
|
|
13
|
+
}
|
|
14
|
+
export function spawnInactiveWindow(file,args,cwd,{invoke=spawnSync}={}){
|
|
15
|
+
const payload=Buffer.from(JSON.stringify({file,parameters:args.map(quoteWindowsArgument).join(' '),cwd}),'utf8').toString('base64');
|
|
16
|
+
const result=invoke(PS_EXE,['-NoProfile','-NonInteractive','-File',fileURLToPath(new URL('./inactive-window.ps1',import.meta.url)),'-Payload',payload],{cwd,windowsHide:true,shell:false,encoding:'utf8',timeout:15000});
|
|
17
|
+
if(result.error||result.status!==0)throw new Error('INACTIVE_WINDOW_LAUNCH_FAILED: '+(result.error?.message||result.stderr||result.status));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Start the same Win32 no-activate helper without holding the scheduler while
|
|
21
|
+
// Windows Terminal finishes creating its window. The helper briefly restores
|
|
22
|
+
// the initiating foreground window and exits on its own.
|
|
23
|
+
export function spawnInactiveWindowDetached(file,args,cwd,{invoke=spawn}={}){
|
|
24
|
+
const payload=Buffer.from(JSON.stringify({file,parameters:args.map(quoteWindowsArgument).join(' '),cwd}),'utf8').toString('base64');
|
|
25
|
+
let child;
|
|
26
|
+
try{
|
|
27
|
+
child=invoke(PS_EXE,['-NoProfile','-NonInteractive','-File',fileURLToPath(new URL('./inactive-window.ps1',import.meta.url)),'-Payload',payload],{cwd,windowsHide:true,shell:false,stdio:'ignore'});
|
|
28
|
+
}catch(error){throw new Error('INACTIVE_WINDOW_LAUNCH_FAILED: '+(error?.message||String(error)));}
|
|
29
|
+
if(!child||typeof child.unref!=='function')throw new Error('INACTIVE_WINDOW_LAUNCH_FAILED: helper did not return a detached process');
|
|
30
|
+
child.unref();
|
|
31
|
+
return child;
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
param([Parameter(Mandatory=$true)][string]$Payload)
|
|
2
|
+
$ErrorActionPreference='Stop'
|
|
3
|
+
try {
|
|
4
|
+
$request=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload)) | ConvertFrom-Json
|
|
5
|
+
Add-Type -TypeDefinition @'
|
|
6
|
+
using System;
|
|
7
|
+
using System.Runtime.InteropServices;
|
|
8
|
+
public static class OrchestratorInactiveShell {
|
|
9
|
+
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] public struct Startup {
|
|
10
|
+
public int cb; public string reserved,desktop,title; public uint x,y,xSize,ySize,xCount,yCount,fill,flags;
|
|
11
|
+
public short show,reserved2;public IntPtr reservedPtr,input,output,error;
|
|
12
|
+
}
|
|
13
|
+
[StructLayout(LayoutKind.Sequential)] public struct ProcessInfo {public IntPtr process,thread;public uint pid,tid;}
|
|
14
|
+
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)]
|
|
15
|
+
static extern bool CreateProcessW(string app,System.Text.StringBuilder command,IntPtr pa,IntPtr ta,bool inherit,uint flags,IntPtr env,string cwd,ref Startup startup,out ProcessInfo info);
|
|
16
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
|
17
|
+
[DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
|
|
18
|
+
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr handle);
|
|
19
|
+
public static void Launch(string file,string parameters,string cwd){
|
|
20
|
+
var baseline=GetForegroundWindow();
|
|
21
|
+
var s=new Startup();s.cb=Marshal.SizeOf(s);s.flags=1;s.show=4;ProcessInfo p;
|
|
22
|
+
if(!CreateProcessW(file,new System.Text.StringBuilder("\""+file+"\" "+parameters),IntPtr.Zero,IntPtr.Zero,false,0x10,IntPtr.Zero,cwd,ref s,out p))throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
|
23
|
+
CloseHandle(p.thread);CloseHandle(p.process);
|
|
24
|
+
var until=Environment.TickCount+2500;
|
|
25
|
+
while(Environment.TickCount<until){
|
|
26
|
+
if(baseline!=IntPtr.Zero&&GetForegroundWindow()!=baseline)SetForegroundWindow(baseline);
|
|
27
|
+
System.Threading.Thread.Sleep(20);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
'@
|
|
32
|
+
[OrchestratorInactiveShell]::Launch([string]$request.file,[string]$request.parameters,[string]$request.cwd)
|
|
33
|
+
} catch {
|
|
34
|
+
[Console]::Error.WriteLine('INACTIVE_WINDOW_LAUNCH_FAILED: '+$_.Exception.Message)
|
|
35
|
+
exit 1
|
|
36
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {probeCli} from './host-probe.mjs';
|
|
4
|
+
const HELP_FLAGS=['--prompt','--output-format','--auto'];
|
|
5
|
+
export function resolveKimiHost(env=process.env) {
|
|
6
|
+
const explicit=env.ORCH_CLI||env.KIMI_EXE;
|
|
7
|
+
const candidates=explicit?[path.resolve(explicit)]:[
|
|
8
|
+
...String(env.PATH||env.Path||'').split(path.delimiter).filter(Boolean).flatMap(dir=>['kimi.exe','kimi.cmd','kimi'].map(n=>path.join(dir,n))),
|
|
9
|
+
...(env.USERPROFILE?[path.join(env.USERPROFILE,'.kimi-code','bin','kimi.exe')]:[]),
|
|
10
|
+
];
|
|
11
|
+
const rejected=[];
|
|
12
|
+
for(const file of [...new Set(candidates)]) {
|
|
13
|
+
if(!fs.existsSync(file)||!fs.statSync(file).isFile())continue;
|
|
14
|
+
let bin=file,prefixArgs=[];
|
|
15
|
+
if(!/\.exe$/i.test(file)) {
|
|
16
|
+
if(/\.[cm]?js$/i.test(file)) {
|
|
17
|
+
const sibling=path.join(path.dirname(file),'node.exe');bin=fs.existsSync(sibling)?sibling:process.execPath;prefixArgs=[file];
|
|
18
|
+
} else {
|
|
19
|
+
// The extensionless native launcher sits next to kimi.exe; prefer the explicit exe.
|
|
20
|
+
const sibling=path.join(path.dirname(file),'kimi.exe');if(!fs.existsSync(sibling))continue;bin=sibling;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const label=[bin,...prefixArgs].join(' ');
|
|
24
|
+
const help=probeCli(bin,[...prefixArgs,'--help'],{env});
|
|
25
|
+
const missing=HELP_FLAGS.filter(f=>!help.out.includes(f));
|
|
26
|
+
if(help.status!==0||missing.length){rejected.push(`${label}: --help ${help.error?'probe error '+help.error:'exit '+help.status}${missing.length?'; missing '+missing.join(' '):''}`);continue;}
|
|
27
|
+
const versionResult=probeCli(bin,[...prefixArgs,'--version'],{env});
|
|
28
|
+
const version=versionResult.stdout.trim();
|
|
29
|
+
if(versionResult.status!==0||!/^\d+\.\d+\.\d+$/.test(version)){rejected.push(`${label}: --version ${versionResult.error?'probe error '+versionResult.error:'exit '+versionResult.status+'; unusable output '+JSON.stringify(version.slice(0,120))}`);continue;}
|
|
30
|
+
return {agent:'kimi',config:{id:'kimi',label:'Kimi Code CLI',version,bin,prefixArgs,available:true,native:prefixArgs.length===0,completionMarker:'__ORCH_DONE__',modelEnv:'ORCH_MODEL'}};
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`CLI_MISSING: kimi; no supported installed Kimi Code CLI found. No fallback or automatic install. Tried: ${rejected.join(' | ')||'no candidate found on disk'}`);
|
|
33
|
+
}
|
|
34
|
+
// Base argv for `kimi -p` (print mode auto-approves routine tool calls per product
|
|
35
|
+
// contract; --prompt cannot combine with --auto/--yolo). The prompt itself is
|
|
36
|
+
// appended by the caller as the final argv element. `-m` MUST precede `-p`: kimi's
|
|
37
|
+
// parser binds `-p`'s value greedily, so `-p -m <model> <prompt>` mis-parses the
|
|
38
|
+
// model as a positional subcommand ("unknown command '<model>'").
|
|
39
|
+
export function kimiArgs(config,{model}={}) {
|
|
40
|
+
return [...(config.prefixArgs||[]),...(model&&String(model).trim()&&!/\s/.test(String(model))?['-m',String(model).trim()]:[]),'-p'];
|
|
41
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {spawn} from 'node:child_process';
|
|
4
|
+
import {kimiArgs} from './kimi-host.mjs';
|
|
5
|
+
import {nativeArgumentLines} from './native-argv.mjs';
|
|
6
|
+
|
|
7
|
+
const quote=(s)=>"'"+String(s).replaceAll("'","''")+"'";
|
|
8
|
+
|
|
9
|
+
// Parse `kimi -p --output-format stream-json` event stream: the last assistant
|
|
10
|
+
// message content wins; meta lines (system.version / session.resume_hint) and
|
|
11
|
+
// tool progress on stderr are ignored.
|
|
12
|
+
export function kimiAssistantText(raw) {
|
|
13
|
+
let final='';
|
|
14
|
+
for(const line of String(raw).split(/\r?\n/)) {
|
|
15
|
+
if(!line.trim())continue;
|
|
16
|
+
try {
|
|
17
|
+
const ev=JSON.parse(line);
|
|
18
|
+
if(ev.type&&/error/i.test(String(ev.type)))throw new Error('KIMI_RESPONSE_FAILED: '+String(ev.message||ev.content||ev.type));
|
|
19
|
+
if(ev.role==='assistant'&&typeof ev.content==='string'&&ev.content.trim())final=ev.content;
|
|
20
|
+
} catch(error) {
|
|
21
|
+
if(error.message&&error.message.startsWith('KIMI_RESPONSE_FAILED'))throw error;
|
|
22
|
+
// ignore non-JSON lines / partial events
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return final;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// kimi 冷启动实测 ~295s(全新会话首次规划调用),远超 codex(60s)/pi(60s)/opencode(90s) 的默认拆块
|
|
29
|
+
// 超时——90s 默认下 kimi 发起的全新请求规划必超时、回退单块 inline 不开窗。默认按 adapter 放宽到
|
|
30
|
+
// 360s(实测值 + 余量);ORCH_DECOMPOSE_TIMEOUT_MS 显式设置时仍优先。
|
|
31
|
+
export const KIMI_DECOMPOSE_TIMEOUT_DEFAULT_MS = 360000;
|
|
32
|
+
export function plannerTimeoutMs(env = process.env) {
|
|
33
|
+
const v = Number(env.ORCH_DECOMPOSE_TIMEOUT_MS);
|
|
34
|
+
return Number.isFinite(v) && v > 0 ? v : KIMI_DECOMPOSE_TIMEOUT_DEFAULT_MS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runKimiPlanner(config,prompt,cwd) {
|
|
38
|
+
// kimi's parser binds -p's value greedily: the prompt must immediately follow
|
|
39
|
+
// -p, before any further flags (same rule as the window argv; b3d3600).
|
|
40
|
+
const args=[...kimiArgs(config,{model:process.env.ORCH_MODEL}),prompt,'--output-format','stream-json'];
|
|
41
|
+
return new Promise((resolve,reject)=>{
|
|
42
|
+
const child=spawn(config.bin,args,{cwd,env:process.env,windowsHide:true,shell:false,stdio:['ignore','pipe','pipe']});
|
|
43
|
+
let raw='',errorText='',timedOut=false;
|
|
44
|
+
const timer=setTimeout(()=>{timedOut=true;child.kill();},plannerTimeoutMs());
|
|
45
|
+
child.stdout.on('data',(b)=>{raw+=b;if(raw.length>8000000)child.kill();});
|
|
46
|
+
child.stderr.on('data',(b)=>{errorText+=b;});
|
|
47
|
+
child.on('error',(e)=>{clearTimeout(timer);reject(e);});
|
|
48
|
+
child.on('exit',(code)=>{
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
try {
|
|
51
|
+
if(timedOut||code!==0)throw new Error(timedOut?'KIMI_PLANNER_TIMEOUT':'KIMI_PLANNER_FAILED: exit '+code);
|
|
52
|
+
const final=kimiAssistantText(raw);
|
|
53
|
+
if(!final)throw new Error('KIMI_PLANNER_EMPTY_RESPONSE'+(errorText?': '+String(errorText).slice(0,300):''));
|
|
54
|
+
resolve({final});
|
|
55
|
+
} catch(e){reject(e);}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function writeKimiLauncher(config,{key,prompt,suffix,workspace,temp}) {
|
|
61
|
+
const stem=String(key+'-'+suffix).replace(/[^a-zA-Z0-9_-]/g,'-');
|
|
62
|
+
const token=/【本块唯一标识】\s*([A-Za-z0-9][A-Za-z0-9_-]*)/.exec(prompt)?.[1];
|
|
63
|
+
if(!token)throw new Error('Kimi window requires a completion token');
|
|
64
|
+
fs.mkdirSync(temp,{recursive:true});
|
|
65
|
+
const lp=path.join(temp,'win-launch-'+stem+'.ps1');
|
|
66
|
+
const pidf=path.join(temp,'window-'+stem+'.pid');
|
|
67
|
+
const pf=path.join(temp,'agent-win-'+stem+'.md');
|
|
68
|
+
const rf=path.join(temp,'win-'+stem+'.result.json');
|
|
69
|
+
const log=path.join(temp,'win-'+stem+'.out.log');
|
|
70
|
+
fs.writeFileSync(pf,prompt,'utf8');
|
|
71
|
+
const args=kimiArgs(config,{model:process.env.ORCH_MODEL});
|
|
72
|
+
const body=[
|
|
73
|
+
"$ErrorActionPreference = 'Continue'",
|
|
74
|
+
'trap { exit 0 }',
|
|
75
|
+
`$PID | Set-Content -LiteralPath ${quote(pidf)} -Encoding UTF8`,
|
|
76
|
+
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
77
|
+
'$OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
78
|
+
'chcp 65001 | Out-Null',
|
|
79
|
+
'[Console]::InputEncoding = [System.Text.Encoding]::UTF8',
|
|
80
|
+
'Write-Host "============================================="',
|
|
81
|
+
'Write-Host " KIMI TASK RUNNING - closes automatically on completion"',
|
|
82
|
+
'Write-Host "============================================="',
|
|
83
|
+
`Set-Location -LiteralPath ${quote(workspace)}`,
|
|
84
|
+
`$env:ORCH_WINDOW = '1'`,
|
|
85
|
+
`$token = ${quote(token)}`,
|
|
86
|
+
`$log = ${quote(log)}`,
|
|
87
|
+
`$resultFile = ${quote(rf)}`,
|
|
88
|
+
"function Write-Result {",
|
|
89
|
+
" param([int]$code,[string]$report)",
|
|
90
|
+
" try {",
|
|
91
|
+
" $json = '{\"__EXIT__\":' + $code + ',\"__DONE__\":true,\"__REPORT__\":' + (ConvertTo-Json $report -Compress) + '}'",
|
|
92
|
+
" $d = Split-Path -Parent $resultFile; if ($d -and -not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }",
|
|
93
|
+
" Set-Content -LiteralPath $resultFile -Value ($json + \"`r`n__EXIT__=\" + $code) -Encoding UTF8",
|
|
94
|
+
" try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }",
|
|
95
|
+
" } catch { }",
|
|
96
|
+
"}",
|
|
97
|
+
"$p = (Get-Content -Raw -Encoding UTF8 '"+String(pf).replace(/'/g,"''")+"').Trim()",
|
|
98
|
+
"if ([string]::IsNullOrWhiteSpace($p)) { Write-Result 2 ''; exit 0 }",
|
|
99
|
+
// kimi takes the prompt as an argv element: fold it into the array BEFORE the
|
|
100
|
+
// Windows PowerShell 5.1 native-argv fix-up so embedded quotes/newlines survive.
|
|
101
|
+
"$agentArgs = @(" + args.map(quote).join(',') + ') + @($p)',
|
|
102
|
+
...nativeArgumentLines(),
|
|
103
|
+
// kimi 把 version/thinking/进度等诊断写到 stderr;2>&1 合并时 PS5.1 会把每行包成 ErrorRecord
|
|
104
|
+
// 刷出红色 NativeCommandError 块(纯外观)。过一道 ErrorRecord 还原:诊断行以普通文本进日志,
|
|
105
|
+
// 窗内不再刷红,stdout 的完成 token 不受影响。
|
|
106
|
+
`& ${quote(config.bin)} @agentArgs 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.TargetObject } else { $_ } } | Tee-Object -FilePath $log`,
|
|
107
|
+
"$code = $LASTEXITCODE",
|
|
108
|
+
// kimi -p exits on its own after the turn; capture the tail containing the token.
|
|
109
|
+
"$report = ''",
|
|
110
|
+
"if (Test-Path -LiteralPath $log) {",
|
|
111
|
+
" $lines = Get-Content -LiteralPath $log -Encoding UTF8 -ErrorAction SilentlyContinue",
|
|
112
|
+
" $hit = $lines | Where-Object { $_.Contains($token) } | Select-Object -Last 1",
|
|
113
|
+
" if ($hit) { $report = $hit } else { $report = ($lines | Select-Object -Last 3) -join \"`n\" }",
|
|
114
|
+
"}",
|
|
115
|
+
"Write-Result $code $report",
|
|
116
|
+
'exit 0',
|
|
117
|
+
].join('\r\n');
|
|
118
|
+
fs.writeFileSync(lp,'\uFEFF'+body,'utf8');
|
|
119
|
+
return {lp,pidf,pf,rf,log};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function waitKimiResult(rf,timeoutMs,pidf) {
|
|
123
|
+
const end=Date.now()+timeoutMs;
|
|
124
|
+
while(Date.now()<end) {
|
|
125
|
+
if(fs.existsSync(rf)) {
|
|
126
|
+
try {
|
|
127
|
+
// Write-Result appends a human-readable `__EXIT__=N` trailer line (read by
|
|
128
|
+
// readExitCode); only the first line is the JSON record.
|
|
129
|
+
const r=JSON.parse(fs.readFileSync(rf,'utf8').replace(/^\uFEFF/,'').split(/\r?\n/)[0]);
|
|
130
|
+
return {done:true,exitCode:r.__EXIT__,text:r.__REPORT__||''};
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
if(fs.existsSync(pidf)) {
|
|
134
|
+
const pid=Number(fs.readFileSync(pidf,'utf8').replace(/^\uFEFF/,''));
|
|
135
|
+
try{process.kill(pid,0);}catch{return {done:true,exitCode:1,text:'Kimi launcher exited without result'};}
|
|
136
|
+
}
|
|
137
|
+
await new Promise((r)=>setTimeout(r,300));
|
|
138
|
+
}
|
|
139
|
+
return {done:false,exitCode:null,text:'Kimi completion timeout'};
|
|
140
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
param([string]$Directory,[string]$Ticket,[int]$Owner,[string]$Started)
|
|
2
|
+
$ErrorActionPreference='Stop'
|
|
3
|
+
# Mutex wait is tunable: on a loaded machine many contenders legitimately queue past 20s
|
|
4
|
+
# (nine parents contending for eight slots blew the old hard-coded wait under full-suite load).
|
|
5
|
+
$waitMs=60000
|
|
6
|
+
try { if ($env:ORCH_MUTEX_WAIT_MS) { $waitMs=[int]$env:ORCH_MUTEX_WAIT_MS } } catch { }
|
|
7
|
+
if ($waitMs -lt 1000) { $waitMs=1000 }
|
|
8
|
+
$hash=[Security.Cryptography.SHA256]::Create()
|
|
9
|
+
$name='Local\TuiOrchestrator.Leases.'+[BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($Directory.ToLowerInvariant()))).Replace('-','')
|
|
10
|
+
$mutex=[Threading.Mutex]::new($false,$name)
|
|
11
|
+
$held=$false
|
|
12
|
+
try {
|
|
13
|
+
try {$held=$mutex.WaitOne($waitMs)} catch [Threading.AbandonedMutexException] {$held=$true}
|
|
14
|
+
if(-not $held){exit 2}
|
|
15
|
+
[IO.File]::WriteAllText($Ticket+'.ready','ready')
|
|
16
|
+
$poll=0
|
|
17
|
+
while(-not [IO.File]::Exists($Ticket+'.release')) {
|
|
18
|
+
# Owner liveness is a slow CIM query: check it every ~1s, but poll the release file every 100ms
|
|
19
|
+
# so the ticket files are deleted within ~100ms of the caller releasing (was ~1s, which left
|
|
20
|
+
# visible residue and cost a slow query per poll).
|
|
21
|
+
if (($poll % 10) -eq 0) {
|
|
22
|
+
$p=Get-CimInstance Win32_Process -Filter ('ProcessId='+$Owner) -ErrorAction SilentlyContinue
|
|
23
|
+
if(-not $p -or $p.CreationDate.ToUniversalTime().ToString('o') -ne $Started){break}
|
|
24
|
+
}
|
|
25
|
+
$poll++
|
|
26
|
+
Start-Sleep -Milliseconds 100
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
if($held){$mutex.ReleaseMutex()}
|
|
30
|
+
$mutex.Dispose()
|
|
31
|
+
foreach($suffix in @('.ready','.release')){[IO.File]::Delete($Ticket+$suffix)}
|
|
32
|
+
}
|