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,2595 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * open-tui-orchestrator — split a multi-part request into safe parallel blocks.
4
+ *
5
+ * Classification axis is "would running two tasks AT THE SAME TIME break something?"
6
+ * - YES -> linked -> ONE block, strictly serialized (a queue), one window/thread.
7
+ * - NO -> independent -> its OWN block, run in parallel with the others.
8
+ *
9
+ * Modes:
10
+ * --plan "<request>" : decompose only (read-only), print {blocks: [...]}.
11
+ * --run-windows "<request>" : Decompose (read-only), then execute only when a dependency wave
12
+ * contains at least two ready blocks. Independent blocks -> own real
13
+ * visible window, run in PARALLEL; linked blocks -> serialized inside
14
+ * their block. A single block or pure serial plan returns inline with
15
+ * the complete task inventory. Each window auto-closes when its task
16
+ * finishes; results are persisted under workspace/temp/orchestrator.
17
+ * Conflicts with already-running windows are locked (not spawned).
18
+ * --run "<request>" : decompose + execute via the Codex SDK threads (reliable, headless,
19
+ * streams structured events, auto-retries a failed block once,
20
+ * writes temp/win-summary.json with per-block results). For pure
21
+ * automation only -- AGENTS.md forbids headless --run for real changes.
22
+ * --spawn <title> <md> : open ONE real visible agent-interactive window that runs the given
23
+ * prompt/`prompt file`, with a real popup terminal (cmd /c start).
24
+ * Best for when you want to WATCH a specific task.
25
+ *
26
+ * Configuration (all optional, env-first with sensible defaults):
27
+ * WORKSPACE_DIR (default: process.cwd()) — auto-detected workspace root for the user.
28
+ * CODEX_HOME (default: ~/.codex) — 权威 Codex 家目录;跨用户/设备/宿主适配点。
29
+ * ORCH_STATE_DIR (default: $WORKSPACE_DIR/temp/orchestrator) — 本插件的稳定运行/状态目录(registry/锁 等);覆盖值必须仍在 workspace/temp 下。
30
+ * ORCH_SESSION_DIR (default: $CODEX_HOME/sessions) — 会话 rollout 目录。
31
+ * ORCH_SUPERPOWERS_CACHE_DIR (optional) — 覆盖 superpowers 插件缓存目录(不同宿主/市场路径适配)。
32
+ * ORCH_AGENT — host identity; Codex, pi and version-pinned OpenClaw adapters are implemented. No fallback.
33
+ * CODEX_EXE (default: auto-detect from PATH or a common user install).
34
+ * PYTHON (default: `python`; prepended to the spawned window PATH).
35
+ * ORCH_MODEL — optional explicit override. Unset: inherit the initiating window's current
36
+ * model (codex rollout / kimi wire.jsonl / pi settings.json / opencode config;
37
+ * see scripts/core/host-model.mjs); last fallback is the CLI's own config.
38
+ * ORCH_EFFORT — optional explicit thinking-effort override, same inheritance chain as above.
39
+ * ORCH_WINDOW_STAGGER_MS (default: 0) — 同一次 run 内逐窗错峰拉起间隔;突发首回合不再集中打在账号每分钟限流上。
40
+ * ORCH_MAX_WINDOWS (default: 内存换算上限) — 活窗准入上限的额外收紧(不影响规划块数);账号/套餐并发低于本机上限时用它压到安全线。
41
+ *
42
+ * 通用化设计:本插件对【不同 Windows 设备 / 不同用户名 / 不同 agent】自适应——
43
+ * ① 所有敏感路径都走 CODEX_HOME|ORCH_STATE_DIR|ORCH_SESSION_DIR(可用环境变量覆盖),绝不硬编码 "C:\Users\<user>\.codex";ORCH_STATE_DIR 仅允许位于 workspace/temp;
44
+ * ② Host identity and native executable are verified; missing/unsupported hosts fail closed.
45
+ * ③ 任何外部宿主(GUI/CLI/别的 agent)只需在进程环境注入上述可变配置即可运行,无需改本插件源码。
46
+ *
47
+ * Encoding: every text read/write is explicit UTF-8. The window launcher sets
48
+ * [Console]::OutputEncoding/$OutputEncoding = UTF-8 and reads prompts with -Encoding UTF8,
49
+ * so Chinese never becomes mojibake on a Chinese-locale Windows (the old GBK trap).
50
+ *
51
+ * ============================================================================
52
+ * 【铁律 · 一次性并发执行】一旦发起 --run-windows 真实执行:
53
+ * 1) 必须在这一轮里把【全部】可执行任务一次性交给 SDK 拆块并【全部】拉起:
54
+ * independent 并行、linked 串行,同一批里的所有窗同时运行。
55
+ * 2) 发起后【中途不停、不问、不折返、不挑几项、不"下一步再说"、不逐条串行】。
56
+ * 3) 一直轮询到【全部】块都有终态(done/failed/preflight-failed/locked/unfinished),
57
+ * 收齐全部汇报结果才写 win-summary.json 并结束,绝不在"第一扇窗先跑完"就提前收尾。
58
+ * 4) 本窗内【禁止】再调用 --run-windows/--spawn/另开新窗,杜绝 GUI→TUI→TUI 嵌套;
59
+ * 窗自身即被授权执行环境,直接执行并汇报,不以"需要授权/要我修吗/"为由暂停。
60
+ * ============================================================================
61
+ */
62
+ import {nativeArgumentLines} from "./native-argv.mjs";
63
+ import {CORE_VERSION, levelWaves, keysOverlap, summaryComplete,requestTasks,bindClassifiedTasks,ledgerPlan,computeMaxWindows,powershellExe,systemToolExe} from "./contracts.mjs";
64
+ import {effectiveUnitCarrier, ptyAvailability} from "./unit-carrier.mjs";
65
+ // 锚定 SystemRoot 的系统工具:裸名会先按进程 cwd(=任意工作区)解析,防二进制投毒。
66
+ const PS_EXE=powershellExe(),WHERE_EXE=systemToolExe("where.exe");
67
+ import {runWindows,verifyRun} from "./runner.mjs";
68
+ import {launcherIdentity,processSnapshot,sameProcess,coordinatorDir,reserve,bind} from "./leases.mjs";
69
+ import {runPiPlanner,writePiLauncher,waitPiResult} from './pi-runtime.mjs';
70
+ import {runOpenClawPlanner,writeOpenClawLauncher} from './openclaw-runtime.mjs';
71
+ import {runOpenCodePlanner,writeOpenCodeLauncher,waitOpenCodeResult} from './opencode-runtime.mjs';
72
+ import {runKimiPlanner,writeKimiLauncher,waitKimiResult} from './kimi-runtime.mjs';
73
+ import {runClaudePlanner,writeClaudeLauncher,waitClaudeResult} from './claude-runtime.mjs';
74
+ import {runProfilePlanner,writeProfileLauncher,waitProfileResult} from './print-runtime.mjs';
75
+ import {runUnitPlanner,writeUnitLauncher} from './unit-runtime.mjs';
76
+ import {spawnInactiveWindow} from './inactive-window.mjs';
77
+ import {ensureRunGuardian} from './run-guardian.mjs';
78
+ import {lockedForMaintenance,readMaintenanceLock,MAINTENANCE_MESSAGE} from './maintenance-lock.mjs';
79
+ import { networkConfig } from "./network-policy.mjs";
80
+ import { spawn, spawnSync } from "node:child_process";
81
+ import {watchRun, boardSummaryLine} from "./run-board.mjs";
82
+ import { resolveAgent, launchArgs } from "./agent.mjs";
83
+ import { inheritHostModelEffort, hasModelDetector } from "./host-model.mjs";
84
+ import { detectHostAgent } from "./host-cli.mjs";
85
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, rmSync, readdirSync, statSync, statfsSync, openSync, readSync, closeSync, renameSync } from "node:fs";
86
+ import os from "node:os";
87
+ import crypto from "node:crypto";
88
+ import path from "node:path";
89
+ import { fileURLToPath } from "node:url";
90
+ // ---------------------------------------------------------------------------
91
+ // Config (env-first, portable)
92
+ // ---------------------------------------------------------------------------
93
+ // 通用化:仅把 ~/.codex 当作【缺省回退】,真正权威是 CODEX_HOME 环境变量。
94
+ // 这样同一份 orchestrator 可以跑在【不同用户 / 不同设备 / 不同 agent】上——
95
+ // 只要外部(用户/调用方/宿主 agent)设置了 CODEX_HOME(或 ORCH_SESSION_DIR 等),
96
+ // 就不再依赖 hard-coded 的 "C:\Users\<user>\.codex",避免跨用户/跨宿主失效。
97
+ function resolveCodexHome() {
98
+ if (process.env.CODEX_HOME && process.env.CODEX_HOME.trim()) return process.env.CODEX_HOME.trim().replace(/[\\/]+$/, "");
99
+ return path.join(os.homedir(), ".codex");
100
+ }
101
+ const CODEX_HOME = resolveCodexHome();
102
+ import { resolveRuntimeContext } from './runtime-context.mjs';
103
+ // 子代理/窗内公共授权模板加载:把超长 auth 从 SDK 源码下沉到独立模板文件(UTF-8 无 BOM),
104
+ // 运行时读回作为每个窗 prompt 的【公共前缀】,保持字节级一致以命中缓存;读失败回退一段防御文本。
105
+ // 用 import.meta.url 自动推导脚本同目录,绝不硬编码绝对路径;读出的内容必须以 UTF-8 无 BOM 落盘。
106
+ function readAuthPrompt() {
107
+ try {
108
+ const here = path.dirname(fileURLToPath(import.meta.url));
109
+ const pf = path.join(here, "agent-auth-prompt.txt");
110
+ if (existsSync(pf)) {
111
+ const b = readFileSync(pf);
112
+ // 剥 BOM(若编辑器加了 BOM,则按 UTF-8 无 BOM 约定剥除,保证与历史 auth 字节一致)
113
+ const txt = (b[0]===0xEF && b[1]===0xBB && b[2]===0xBF) ? b.subarray(3).toString("utf8") : b.toString("utf8");
114
+ if (txt && txt.trim()) return txt;
115
+ }
116
+ } catch (e) { /* fall through */ }
117
+ return "\n\n(已获授权推进并执行。本窗为 open-tui-orchestrator 授权的真实执行窗:直接在本窗内用自己的工具干活,不要再发起 --run-windows、不要另开新窗、不要以「需要授权/要我修吗」等为由暂停或询问。执行结束再输出结束标记。)";
118
+ }
119
+
120
+ // 调用方自助通道参数在【模块加载期】归一化到 env:AGENT 解析(下方 resolveAgent)与工作区/状态解析
121
+ // 都依赖它们。cli.mjs / orch.mjs 均为 argv 原样透传,因此这里同时覆盖直连 SDK 的调用方。
122
+ let ORCH_ARG_ERROR = null;
123
+ for (const [flag, key] of [['--plan-file', 'ORCH_PLAN_FILE'], ['--unit-cmd', 'ORCH_UNIT_CMD'], ['--unit-cmd-file', 'ORCH_UNIT_CMD_FILE']]) {
124
+ const index = process.argv.indexOf(flag);
125
+ if (index < 0) continue;
126
+ const value = process.argv[index + 1];
127
+ if (!value || value.startsWith('--')) { ORCH_ARG_ERROR = ORCH_ARG_ERROR || (flag + ' needs a value'); continue; }
128
+ process.env[key] = value; process.argv.splice(index, 2);
129
+ }
130
+ const PLAN_FILE = String(process.env.ORCH_PLAN_FILE || '').trim() || null;
131
+ const { workspace: WORKSPACE, temp: TMP, state: STATE_DIR } = resolveRuntimeContext();
132
+ process.env.WORKSPACE_DIR = WORKSPACE;
133
+ process.env.ORCH_STATE_DIR = STATE_DIR;
134
+ // 宿主解析容忍化(caller-driven 通道):只读入口(--tasks/--status/--doctor/--watch/--verify-run/--plan --plan-file)
135
+ // 不需要宿主身份;动作入口在分发处显式 requireHost(),fail-closed 语义不变(错误对象原样抛出)。
136
+ const AGENT = (() => {
137
+ try { return { ...resolveAgent(), error: null }; }
138
+ catch (error) { return { agent: String(process.env.ORCH_AGENT || '').trim().toLowerCase() || 'unknown', config: {}, error }; }
139
+ })();
140
+ const AGENT_ERROR = AGENT.error || null;
141
+ function requireHost() { if (AGENT_ERROR) throw AGENT_ERROR; }
142
+ // 单元载体分级:显式 ORCH_UNIT_CARRIER > agent 画像(registry / 调用方单元画像)> stdio。
143
+ function unitCarrier() { return effectiveUnitCarrier(process.env, AGENT.config || {}); }
144
+ // pty 引擎必须在任何单元启动前可解析;缺引擎时给出可操作错误,不让单元静默秒退。
145
+ function assertUnitCarrierReady() {
146
+ const carrier = unitCarrier();
147
+ if (carrier !== 'pty') return carrier;
148
+ const availability = ptyAvailability();
149
+ if (!availability.ok) {
150
+ throw new Error('PTY_CARRIER_UNAVAILABLE: @lydell/node-pty not found (tried: ' + availability.tried.join(' | ')
151
+ + '). Run `npm run setup` (or npm install --prefix scripts), or fall back with ORCH_UNIT_CARRIER=stdio / --mode window.');
152
+ }
153
+ return carrier;
154
+ }
155
+ // Non-codex window completion is a launcher-written result file; each adapter owns its wait.
156
+ const waitAdapterResult=(rf,timeout,pidf)=>AGENT.agent==='opencode'?waitOpenCodeResult(rf,timeout,pidf):AGENT.agent==='kimi'?waitKimiResult(rf,timeout,pidf):AGENT.agent==='claude'?waitClaudeResult(rf,timeout,pidf):(AGENT.config.profile||AGENT.config.unit)?waitProfileResult(rf,timeout,pidf):waitPiResult(rf,timeout,pidf);
157
+ const CODEX = String(AGENT.config?.bin || '');
158
+ // PowerShell 单引号字符串内嵌路径:把 ' 转义成 '',避免路径含引号时破坏脚本/注入。
159
+ const WORKSPACE_Q = WORKSPACE.replace(/'/g, "''");
160
+ const CODEX_Q = CODEX.replace(/'/g, "''");
161
+ // 仅当 PYTHON 是真实存在的绝对路径时,才把它所在目录前置进窗内 PATH;
162
+ // 裸名(如默认的 "python")会成为按窗 cwd(=任意工作区)解析的相对 PATH 段,构成投毒面。
163
+ const PY_DIR = (() => { try { const p = String(process.env.PYTHON || ""); return path.isAbsolute(p) && existsSync(p) ? path.dirname(p) : null; } catch { return null; } })();
164
+ // 发起窗 model/thinking 继承(同窗即同 agent,逐字透传):显式 ORCH_MODEL/ORCH_EFFORT 优先;
165
+ // 未显式给出时检测发起窗口当前实际值并写回 env,下游 argv 构造与 codex -m/-c 注入全部消费这两个变量。
166
+ // 调用方自助通道(unit)下执行者由调用方指定,档位改从「发起者」身份探测(发起者≠执行者是常态)。
167
+ // 档位去向全部留痕(MODEL_PLAN → summary.json 的 model/effort/modelSource/effortSource):
168
+ // explicit / 继承来源 / host-default(宿主自身配置兜底,且打印明确声明,禁止静默换档)。
169
+ // node --test 下由 inheritHostModelEffort 自行豁免(NODE_TEST_CONTEXT)。
170
+ // 机器可读入口(--doctor/--tasks/--status/--watch/--verify-run 等)的 stdout 只允许纯数据:
171
+ // 档位解读性输出一律让位——信息已进 doctor 字段与 summary 留痕。
172
+ const MACHINE_READABLE = process.argv.slice(2).some((a) => /^--(doctor|tasks|status|watch|verify-run|version|help|install-deps)$/.test(a) || a === '-h');
173
+ const noteModel = (line) => { if (!MACHINE_READABLE) console.log(line); };
174
+ let MODEL_PLAN = {model:null,effort:null,modelSource:'none',effortSource:'none'};
175
+ if (!AGENT_ERROR) try {
176
+ const explicitModel = String(process.env.ORCH_MODEL || '').trim() || null;
177
+ const explicitEffort = String(process.env.ORCH_EFFORT || '').trim() || null;
178
+ let inheritFrom = AGENT.agent;
179
+ if (AGENT.config.unit) {
180
+ try { const hostId = detectHostAgent(process.env); if (hostId && hostId !== AGENT.agent) inheritFrom = hostId; } catch { /* 发起者未知:保持执行者身份 */ }
181
+ }
182
+ const inherited = inheritHostModelEffort(inheritFrom, { cwd: WORKSPACE });
183
+ const model = String(process.env.ORCH_MODEL || '').trim() || null;
184
+ const effort = String(process.env.ORCH_EFFORT || '').trim() || null;
185
+ MODEL_PLAN = { model, effort,
186
+ modelSource: explicitModel ? 'explicit' : (inherited && inherited.model ? inherited.source : 'host-default'),
187
+ effortSource: explicitEffort ? 'explicit' : (inherited && inherited.effort ? inherited.source : 'host-default') };
188
+ if (explicitModel || explicitEffort) noteModel(`[orchestrator] model/effort: explicit (model=${model || '-'} effort=${effort || '-'})`);
189
+ else if (inherited) noteModel(`[orchestrator] inherit host model/effort (${inheritFrom}${inheritFrom !== AGENT.agent ? ' as initiator' : ''}/${inherited.source}): model=${inherited.model || '-'} effort=${inherited.effort || '-'}`);
190
+ else if (hasModelDetector(inheritFrom)) noteModel(`[orchestrator] model/effort: no initiator session record for ${inheritFrom}; using its own CLI config/default (model=${model || '-'} effort=${effort || '-'}; pin with ORCH_MODEL/ORCH_EFFORT)`);
191
+ } catch { /* 检测失败不阻断;MODEL_PLAN 保持 none */ }
192
+ // Leave model selection to the host CLI unless explicitly overridden.
193
+ const MODEL = process.env.ORCH_MODEL || undefined;
194
+ const EFFORT = process.env.ORCH_EFFORT || undefined;
195
+ // A caller may pass its effective permissions; never elevate implicitly.
196
+ const SANDBOX = process.env.ORCH_SANDBOX || "danger-full-access";
197
+ const APPROVAL = "never";
198
+ const AGENT_BIN = String(AGENT.config?.bin || '');
199
+ const AGENT_BIN_Q = String(AGENT_BIN).replace(/'/g, "''");
200
+ // exec 一次性模式参数:config 已含 approval_policy=never/sandbox,仅剔除交互专用 -a 并在最前加 exec。
201
+ // 模型/推理力度透传给窗内 codex:MODEL/EFFORT 非空且值不含空格时才追加 -m / -c(-c 用 model_reasoning_effort=<EFFORT>)。
202
+ function appendModelEffortArgs(arr, agentConfig) {
203
+ const out = [...arr];
204
+ // 通用化:按 agent 各自的 modelEnv/effortEnv 取值(默认 codex 用 ORCH_MODEL/ORCH_EFFORT);
205
+ // 只有 codex 才注入 codex 专属 -m/-c model_reasoning_effort=;非 codex 走 CLI 自身环境变量。
206
+ const cfg = agentConfig || { id: "codex", modelEnv: "ORCH_MODEL", effortEnv: "ORCH_EFFORT" };
207
+ const id = String(cfg.id || "codex");
208
+ const modelEnv = cfg.modelEnv || "ORCH_MODEL";
209
+ const effortEnv = cfg.effortEnv || "ORCH_EFFORT";
210
+ // Explicit overrides only; otherwise the selected CLI owns configuration.
211
+ const model = process.env[modelEnv] || (id === "codex" ? MODEL : "");
212
+ const effort = process.env[effortEnv] || (id === "codex" ? EFFORT : "");
213
+ if (id !== "codex") {
214
+ // 非 codex:模型/力度经 CLI 自身环境变量(ANTHROPIC_MODEL/GEMINI_MODEL 等)注入,绝不塞 codex 专属 argv。
215
+ if (model && String(model).trim() && !/\s/.test(String(model))) { try { process.env[modelEnv] = String(model); } catch { /* ignore */ } }
216
+ if (effort && String(effort).trim() && !/\s/.test(String(effort))) { try { process.env[effortEnv] = String(effort); } catch { /* ignore */ } }
217
+ return out;
218
+ }
219
+ if (model && String(model).trim() && !/\s/.test(String(model))) out.push("-m", String(model));
220
+ if (effort && String(effort).trim() && !/\s/.test(String(effort))) out.push("-c", "model_reasoning_effort=" + String(effort));
221
+ return out;
222
+ }
223
+ // 生成 PowerShell 数组字面量 @('a','b',...)——每个参数独立元素, 含空格/单引号的值也保持为一个参数,
224
+ // 绝不拆裂。用于 & codex @agentArgs 安全传参(替代 AGENT_ARGS.split(" ") 会裂开含空格路径的旧式拼接)。
225
+ export function buildPsArrayLit(args) {
226
+ const a = Array.isArray(args) ? args : [];
227
+ if (!a.length) return "@()";
228
+ return "@(" + a.map((x) => "'" + String(x).replace(/'/g, "''") + "'").join(",") + ")";
229
+ }
230
+ const AGENT_ARGS_EXEC = (() => {
231
+ if (AGENT_ERROR) return '';
232
+ const raw = launchArgs(AGENT.config, { cwd: WORKSPACE, approval: APPROVAL, sandbox: SANDBOX });
233
+ const cleaned = [];
234
+ for (let i = 0; i < raw.length; i++) { if (raw[i] === "-a") { i++; continue; } cleaned.push(raw[i]); }
235
+ return ["exec", ...appendModelEffortArgs(cleaned, AGENT.config)].map((aa) => "'" + String(aa).replace(/'/g, "''") + "'").join(" ");
236
+ })();
237
+ const AGENT_ARGS_LIST = AGENT_ERROR ? [] : appendModelEffortArgs(launchArgs(AGENT.config, { cwd: WORKSPACE, approval: APPROVAL, sandbox: SANDBOX }), AGENT.config);
238
+ // AGENT_ARGS 保留给老式拼接(AGENT_ARGS_EXEC / 兜底),主 launcher 改用 buildPsArrayLit 生成的数组字面量。
239
+ const AGENT_ARGS = AGENT_ARGS_LIST.map((aa) => "'" + String(aa).replace(/'/g, "''") + "'").join(" ");
240
+
241
+ // Runtime state belongs to the initiating workspace temp/orchestrator directory.
242
+
243
+ // 兼容保留:子代理相关目录(本插件当前不再使用子代理;该目录仅作兼容,不用于启用子代理)。
244
+ // 放在 orchestrator 稳定家目录下(ORCH_STATE_DIR),非 WORKSPACE 临时目录,避免被插件升级清掉/散落。
245
+ //
246
+ // 【窗内不使用子代理(用户明确)】orchestrator 发起的每个 TUI 窗【不再使用子代理】:所有工作由该窗自己直接完成。
247
+ // 可靠并行 = 多 TUI 窗(独立块并行);任务完成判定 = 窗内最终落盘产物。历史:曾尝试
248
+ // codex goal 机制与"共享任务文件 + spawn/wait"(均为线程级/消息正文投递在此环境不可靠),均已弃用。
249
+ //
250
+ // 轮询总上限:默认 6 小时(ORCH_MAX_POLL_MS 可调)。用于「检测是否有默认时间限制会提前结束轮询」——
251
+ // 在这里显式兜底为 6 小时,绝不让任何更短的默认值把还没跑完的 TUI 窗提前判成超时。
252
+ // ORCH_TIMEOUT_MS 仍可覆盖(比如测试时缩短)。
253
+ const POLL_TIMEOUT_MS = Number(process.env.ORCH_TIMEOUT_MS || process.env.ORCH_MAX_POLL_MS || 21600000);
254
+ // 窗内 watchdog 的观察上限随父进程轮询上限走(默认 = 轮询上限 + 10 分钟,可用 ORCH_WATCHDOG_MAX_MIN 覆盖)。
255
+ // 原因:watchdog 若固定 45 分钟,长任务在父进程意外退出后会失去“完成即自关”的后盾,遗留孤儿窗。
256
+ process.env.ORCH_WATCHDOG_MAX_MIN = process.env.ORCH_WATCHDOG_MAX_MIN || String(Math.max(45, Math.ceil(POLL_TIMEOUT_MS / 60000) + 10));
257
+ // Terminal hosting the visible agent TUI window.
258
+ // wt -> Windows Terminal (best IME/TSF support for CJK input; the fix for
259
+ // "微软拼音输入偶发失效"). Requires wt.exe (WindowsApps).
260
+ // conhost -> classic console (reliable window, but IME is limited for TUI apps).
261
+ // Default = Windows Terminal. ORCH_TUI=wt is confirmed EXPLICITLY below (the env var
262
+ // defaults to "wt"), so the visible agent TUI window is hosted in Windows Terminal
263
+ // whenever wt.exe is present; conhost is only the runtime fallback when wt.exe is missing.
264
+ const WT_EXE =
265
+ process.platform === "win32"
266
+ ? path.join(process.env.LOCALAPPDATA || "", "Microsoft", "WindowsApps", "wt.exe")
267
+ : "";
268
+ // Node's fs.existsSync returns false for the WindowsApps App Execution Alias reparse point (statSync
269
+ // throws EACCES), but the alias IS usable — `where.exe wt.exe` resolves it. Without this check the TUI
270
+ // fell back to conhost, which is the worst host for CJK IME per the skill's ORCH_TUI guidance.
271
+ function wtAvailable() {
272
+ if (!WT_EXE) return false;
273
+ try {
274
+ const r = spawnSync(WHERE_EXE, ["wt.exe"], { encoding: "utf8", windowsHide: true });
275
+ return r.status === 0 && !/ERROR:/.test(String(r.stdout || ""));
276
+ } catch { return false; }
277
+ }
278
+ // Explicitly confirm ORCH_TUI=wt (Windows Terminal) so it is a real env value that also
279
+ // propagates to the spawned window processes, instead of only an implicit code default.
280
+ process.env.ORCH_TUI = process.env.ORCH_TUI || "wt";
281
+ const TUI_MODE = String(process.env.ORCH_TUI).toLowerCase();
282
+ const codex = AGENT.agent==='codex'?new (await import('@openai/codex-sdk')).Codex({ codexPathOverride: CODEX, config: networkConfig }):null;
283
+ // 让出 CPU:orchestrator(含拆解线程)以 BelowNormal 优先级跑,不抢占系统/其它任务(限速=80% 性能,慢慢跑)。
284
+ try { process.setPriority(6); console.log("[orchestrator] priority=BelowNormal (让出CPU)"); } catch { /* ignore */ }
285
+ function threadBase() {
286
+ return {
287
+ model: MODEL,
288
+ modelReasoningEffort: EFFORT,
289
+ workingDirectory: WORKSPACE,
290
+ skipGitRepoCheck: true,
291
+ sandboxMode: SANDBOX,
292
+ approvalPolicy: APPROVAL,
293
+ };
294
+ }
295
+ // ============================================================================
296
+ // 两段式拆块:阶段 A = LLM 定性(扁平任务清单);阶段 B = 确定性分块引擎(代码)。
297
+ // LLM 只做它擅长的「语义定性」(归属/依赖/可行性),代码负责确定性的分组/键规范化/wave,
298
+ // 既利用 LLM 的语义判断,又不依赖它稳定产出复杂嵌套 blocks JSON(此前 DeepSeek 常 agentic/空输出)。
299
+ // ============================================================================
300
+ // 模块→项目 归属提示(通用、非硬编码):调用方经 ORCH_TASK_HINTS 传 JSON 映射,
301
+ // 例 {"会员管理":"82","商品与分类":"82","订单管理":"82"}。用于把「没写项目号但属于某项目」的任务归位。
302
+ function resolveHints() {
303
+ const hints = { moduleToProj: {}, foldRules: {}, tailKeywords: ['清爽','清理','保持干净','收尾','整理'] };
304
+ try {
305
+ const raw = process.env.ORCH_TASK_HINTS;
306
+ if (raw && raw.trim()) { const j = JSON.parse(raw); if (j && typeof j === "object" && !Array.isArray(j)) hints.moduleToProj = j; }
307
+ } catch { /* ignore */ }
308
+ try {
309
+ const raw = process.env.ORCH_TASK_FOLD;
310
+ if (raw && raw.trim()) { const j = JSON.parse(raw); if (j && typeof j === "object" && !Array.isArray(j)) hints.foldRules = j; }
311
+ } catch { /* ignore */ }
312
+ try {
313
+ const raw = process.env.ORCH_TASK_TAIL;
314
+ if (raw && raw.trim()) { const arr = JSON.parse(raw); if (Array.isArray(arr)) hints.tailKeywords = arr.map(String); }
315
+ } catch { /* ignore */ }
316
+ return hints;
317
+ }
318
+ // 阶段 A:LLM 定性提示词(扁平任务清单,不分块)。关键:字段少、只定性、禁工具、纯 JSON。
319
+ function classifyPrompt(request, hints = {}) {
320
+ const modToProj = (hints && hints.moduleToProj) || {};
321
+ const hintText = Object.keys(modToProj).length
322
+ ? "\n已知模块→项目映射(校正归属用,JSON):" + JSON.stringify(modToProj)
323
+ : "";
324
+ return (
325
+ "你是任务定性分析器,只做一件事:先建立完整 TODO LIST,再把下面请求拆成「扁平任务清单」,不要分块、不要合并、不要执行。" +
326
+ "每个自然任务一条,字段如下:id(字符串编号)、summary(保留原意的一句话)、kind(project|tool|system)、" +
327
+ "project(80|81|82|83,只在请求里明确写了才填,否则填 null)、" +
328
+ "hint(该任务涉及模块/资源关键词,如 会员管理、商品、订单)、" +
329
+ "conflict_hints(该任务会改动的具体文件/模块/资源关键词数组,越具体越好,如 [\"file:src/order.js\",\"db:订单\",\"dir:管理端\"];同项目内资源不重叠的任务会被拆成并行窗口,所以每个 project 任务都必须尽量给出,实在不确定才 [])、" +
330
+ "depends_on(依赖的其它任务 id 数组,无则 [])、" +
331
+ "feasible(是否可行,布尔)、already_done(是否已达成无需做,布尔)。" +
332
+ "规则:TODO LIST 必须覆盖全部原始任务;不遗漏、不合并;请求没写项目号时 project 必须为 null,交给模块映射校正,不许猜;" +
333
+ "project 任务的 conflict_hints 不许偷懒留空:留空表示资源不确定,该项目组会被整体串行;给出精确资源关键词,同项目不重叠的任务就能并行开窗;" +
334
+ "禁止调用任何工具/读文件/搜索/联网;只输出一个 JSON 对象 {\"tasks\":[...]},能被 JSON.parse 直接解析,不要 Markdown 代码块、不要任何前后缀文字。" +
335
+ hintText + "\n请求:" + request + '\n以下原始任务清单是权威清单。逐条返回相同的 T001 等 id,不添加、不遗漏、不合并任务。summary 可概述,执行正文由主控按 id 恢复;depends_on 也使用原始 id。\n'+JSON.stringify(requestTasks(request))
336
+ );
337
+ }
338
+ // 阶段 B:确定性分块引擎。消费扁平任务清单 -> 产出 blocks(可行性过滤 + 模块归位 + 工具独立 + 未知串行 + 依赖映射)。
339
+ function buildBlocksFromTasks(tasks, hints = {}) {
340
+ tasks = Array.isArray(tasks) ? tasks : [];
341
+ const modToProj = (hints && hints.moduleToProj) || {};
342
+ const foldRules = (hints && hints.foldRules) || {};
343
+ const tailKeywords = Array.isArray(hints && hints.tailKeywords) ? hints.tailKeywords : [];
344
+ const byProj = {}; // proj-N -> [item]
345
+ const toolItems = []; // kind=tool/system
346
+ const unkItems = []; // 无归属 -> linked 串行(绝不并行孤儿)
347
+ for (const t of tasks) {
348
+ if (!t || typeof t !== "object") continue;
349
+ // Model flags are suggestions, never authority to discard a requested task.
350
+ const summary = String(t.summary || t.prompt || "").trim();
351
+ if (!summary) continue;
352
+ let p = String(t.project || "").replace(/[^0-9]/g, "");
353
+ if (!p) for (const [mod, pn] of Object.entries(modToProj)) { if (summary.includes(mod)) { p = String(pn).replace(/[^0-9]/g, ""); break; } }
354
+ const item = { id: String(t.id || "t" + (byProjKeyCount(byProj) + toolItems.length + unkItems.length + 1)), summary, kind: String(t.kind || ""), depends_on: Array.isArray(t.depends_on) ? t.depends_on.map(String) : [], conflict_hints: Array.isArray(t.conflict_hints) ? t.conflict_hints.map(String) : [] };
355
+ if (p) (byProj["proj-" + p] = byProj["proj-" + p] || []).push(item);
356
+ else if (item.kind === "tool" || item.kind === "system") toolItems.push(item);
357
+ else unkItems.push(item);
358
+ }
359
+ // Validate task-level dependencies BEFORE grouping can hide internal cycles.
360
+ const allItems=[...Object.values(byProj).flat(),...toolItems,...unkItems];
361
+ const orderedItems=levelWaves(allItems.map(it=>({key:it.id,b:{},dependsOn:it.depends_on,it}))).flat().map(r=>r.it);
362
+ const rank=new Map(orderedItems.map((it,i)=>[it.id,i]));
363
+ for(const list of [...Object.values(byProj),toolItems,unkItems])list.sort((a,b)=>rank.get(a.id)-rank.get(b.id));
364
+ // fold 规则:某工具/系统任务命中 fold 关键字 -> 折进指定项目(作为该项目任务一环,不再独立并行,避免与项目竞态)。
365
+ for (const key of Object.keys(foldRules)) {
366
+ const targets = (foldRules[key] || []).map((x) => String(x).replace(/[^0-9]/g, ""));
367
+ for (const list of [toolItems, unkItems]) {
368
+ for (let i = list.length - 1; i >= 0; i--) {
369
+ if (list[i].summary.includes(key)) {
370
+ const tt = list.splice(i, 1)[0];
371
+ for (const pn of targets) {
372
+ (byProj["proj-" + pn] = byProj["proj-" + pn] || []).push({ id: tt.id + "-fold" + pn, summary: tt.summary, sourceTaskId:tt.id, kind: "project", depends_on: tt.depends_on, conflict_hints: tt.conflict_hints });
373
+ }
374
+ }
375
+ }
376
+ }
377
+ }
378
+ const blocks = [];
379
+ const blockItems = []; // 与 blocks 平行的源 item 数组,用于依赖映射
380
+ const hintKeys = (it) => it.conflict_hints.map((h) => (h.includes(":") ? h : "file:" + h));
381
+ for (const [title, arr] of Object.entries(byProj)) {
382
+ arr.sort((a,b)=>rank.get(a.sourceTaskId||a.id)-rank.get(b.sourceTaskId||b.id));
383
+ const clusters = splitProjectGroup(arr, hintKeys);
384
+ for (let ci = 0; ci < clusters.length; ci++) {
385
+ const cluster = clusters[ci];
386
+ const split = clusters.length > 1;
387
+ const btitle = split ? title + "-" + (ci + 1) : title;
388
+ // 精细化拆分:可证明资源不重叠的同项目任务各自成块,只带精确 hint 键(不带 dir:proj-N,
389
+ // 否则兄弟块互相撞 key 又被串行);未拆分时维持原样:单块 + dir 键兜底串行。
390
+ const keys = split
391
+ ? [...new Set(cluster.flatMap(hintKeys))]
392
+ : ["dir:" + title, ...cluster.flatMap(hintKeys)];
393
+ const prompt = "仅处理项目 " + title + "。任务:" + cluster.map((a) => a.summary).join(";") + "。不要处理其它项目编号。"
394
+ + (split ? "本项目任务已按资源拆分为多个并行窗口,本窗只改动本块任务涉及的文件/资源。" : "");
395
+ blocks.push({ type: "independent", title: btitle, dependsOn: [], conflictKeys: keys, tasks: [{ id: "t" + (blocks.length + 1), title: btitle, prompt }] });
396
+ blockItems.push(cluster);
397
+ }
398
+ }
399
+ for (const it of toolItems) {
400
+ const title = "task-" + (blocks.length + 1);
401
+ blocks.push({ type: "independent", title, dependsOn: [], conflictKeys: it.conflict_hints.map((h) => (h.includes(":") ? h : "file:" + h)), tasks: [{ id: "t" + (blocks.length + 1), title, prompt: it.summary }] });
402
+ blockItems.push([it]);
403
+ }
404
+ if (unkItems.length) {
405
+ const title = "linked-ambiguous";
406
+ blocks.push({ type: "linked", title, dependsOn: [], conflictKeys: [], tasks: unkItems.map((it, idx) => ({ id: "t" + (blocks.length + 1) + "-" + (idx + 1), title: it.summary.slice(0, 40), prompt: it.summary })) });
407
+ blockItems.push(unkItems);
408
+ }
409
+ // 依赖映射:item.depends_on (task id) -> 所在块 title
410
+ for (let i = 0; i < blocks.length; i++) {
411
+ const deps = new Set();
412
+ for (const it of blockItems[i]) {
413
+ for (const depId of it.depends_on) {
414
+ for (let j = 0; j < blocks.length; j++) {
415
+ if (j !== i && blockItems[j].some((x) => x.id === depId || x.sourceTaskId === depId)) { deps.add(blocks[j].title); }
416
+ }
417
+ }
418
+ }
419
+ blocks[i].dependsOn = [...deps];
420
+ }
421
+ // tail 规则:命中 tail 关键字的块(收尾/汇总类,如 "最后用Visualize")依赖所有项目块,跑在最后。
422
+ if (tailKeywords.length) {
423
+ const projTitles = blocks.filter((b) => b.title.startsWith("proj-")).map((b) => b.title);
424
+ for (const b of blocks) {
425
+ const text = (b.tasks && b.tasks[0] && b.tasks[0].prompt) || "";
426
+ if (tailKeywords.some((k) => text.includes(k))) {
427
+ b.dependsOn = Array.from(new Set([...(b.dependsOn || []), ...projTitles]));
428
+ }
429
+ }
430
+ }
431
+ return blocks;
432
+ }
433
+ function byProjKeyCount(map) { let n = 0; for (const k in map) n += (map[k] || []).length; return n; }
434
+ // 同项目组内精细化拆分:按 conflict_hints 的资源键重叠做并查集聚类。只有【全部任务都给出
435
+ // 精确 hints】且能分成 ≥2 个互不重叠的簇时才真拆分(同项目不同资源 → 并行窗口);
436
+ // 任一任务 hints 为空(资源说不清)或全部纠缠成一簇时返回 [arr](维持单块串行,安全兜底)。
437
+ // 依赖(depends_on)不参与聚类——拆开后由块间 dependsOn 边保住先后顺序。
438
+ function splitProjectGroup(arr, hintKeys) {
439
+ if (!Array.isArray(arr) || arr.length < 2) return [arr];
440
+ if (arr.some((it) => !Array.isArray(it.conflict_hints) || it.conflict_hints.length === 0)) return [arr];
441
+ const parent = arr.map((_, i) => i);
442
+ const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
443
+ const keys = arr.map((it) => hintKeys(it));
444
+ for (let i = 0; i < arr.length; i++)
445
+ for (let j = i + 1; j < arr.length; j++)
446
+ if (keysOverlap(keys[i], keys[j])) { const ri = find(i); if (ri !== find(j)) parent[ri] = find(j); }
447
+ const clusters = new Map();
448
+ for (let i = 0; i < arr.length; i++) {
449
+ const r = find(i);
450
+ if (!clusters.has(r)) clusters.set(r, []);
451
+ clusters.get(r).push(arr[i]);
452
+ }
453
+ const out = [...clusters.values()];
454
+ return out.length > 1 ? out : [arr];
455
+ }
456
+ /**
457
+ * 【兜底】当 LLM 拆块失败/超时且重试耗尽时,把整条请求保留为【一个 linked 块】(严格串行、无并发冲突)。
458
+ * 该完整账本随后交给开窗策略判断:单块计划返回 inline,由发起对话执行;不会因解析失败丢掉任何原始任务。
459
+ */
460
+ function fallbackPlan(request) {
461
+ return {
462
+ blocks: [
463
+ {
464
+ type: "linked",
465
+ title: "autosafe-serialized",
466
+ dependsOn: [],
467
+ conflictKeys: [],
468
+ tasks: [
469
+ { id: "1", title: "autosafe-serialized", prompt: String(request || "") }
470
+ ]
471
+ }
472
+ ]
473
+ };
474
+ }
475
+ /**
476
+ * 安全拆块:先用 LLM 分类(read-only),失败/超时则重试(ORCH_DECOMPOSE_RETRIES,默认 2),
477
+ * 重试耗尽仍无合法 JSON 时回退到 fallbackPlan —— 完整串行计划,确保整批任务仍可追踪和落地。
478
+ * 返回已 normalize + split 的 blocks;绝不因拆块失败而抛错中止整批。
479
+ */
480
+ async function decomposeSafely(request, maxWindows = computeMaxWindows(os.totalmem() / 1024 ** 3)) {
481
+ const finalize = plan => capPlanBlocks(normalizePlan(plan), maxWindows);
482
+ const hints = resolveHints(); // hints 参与缓存键(moduleToProj/foldRules/tail),防跨项目映射撞缓存
483
+ // 0) 缓存优先:相同请求+相同 hints 的拆块结果永不自动失效(仅 --clear-cache 或删除缓存文件才重置),
484
+ // 命中直接复用 -> 越用越快(与下方 loadDecomposeCache 的“永不自动失效”实现保持一致)。
485
+ const cached = loadCachedDecompose(request, hints);
486
+ if (cached && Array.isArray(cached.blocks) && cached.blocks.length) {
487
+ console.log("[orchestrator] decompose cache HIT -> " + cached.blocks.map((b) => b.title).join(", "));
488
+ return finalize(cached.blocks);
489
+ }
490
+ // 1) 通用确定性启发(非硬编码项目路径):请求里出现多个不同项目目录 -> 单独拆块,秒级返回并写缓存。
491
+ const det = genericProjectSplit(request);
492
+ if (det && Array.isArray(det.blocks) && det.blocks.length) {
493
+ console.log("[orchestrator] generic project split -> " + det.blocks.map((b) => b.title).join(", "));
494
+ saveDecompose(request, det.blocks, hints);
495
+ return finalize(det.blocks);
496
+ }
497
+ // 1.5) 单任务快速路径(通用,非写死路径):明确"单动作"请求 -> 单块计划,跳过 LLM 拆块,秒级+写缓存。
498
+ const sb = trySingleBlock(request);
499
+ if (sb && Array.isArray(sb.blocks) && sb.blocks.length) {
500
+ console.log("[orchestrator] single-block fast path -> " + sb.blocks[0].title);
501
+ saveDecompose(request, sb.blocks, hints);
502
+ return finalize(sb.blocks);
503
+ }
504
+ // 2) 两段式:LLM 定性(扁平任务清单)→ 确定性分块引擎。失败/超时则重试。
505
+ // hints 已上移到函数开头解析(参与缓存键)。
506
+ const attempts = Math.max(1, Number(process.env.ORCH_DECOMPOSE_RETRIES || 2));
507
+ let lastErr = "";
508
+ let seenNoJson = false;
509
+ for (let i = 0; i < attempts; i++) {
510
+ try {
511
+ const res = await runThreadPrompt(classifyPrompt(request, hints) +
512
+ `\nMaximum execution blocks: ${maxWindows}. Return ALL tasks; overflow tasks will be merged into serial blocks. Never omit tasks to meet the window budget.`, { sandboxMode: "read-only" });
513
+ // 模型侧失败(额度用尽/未登录/弱网)以前会被吞掉:只留下「no JSON in classify output: (no valid JSON)」,
514
+ // 读起来像解析 bug,而真正的原因在 runThreadPrompt 的 errored/warnings 里。把它带出来
515
+ // (一行、有界),调用方与操作者才可能自己排障——2026-09-15 真机(codex 额度耗尽)实测缺这一口。
516
+ const modelTrouble = String(res.errored || "").trim() || (Array.isArray(res.warnings) ? res.warnings.map((w) => String(w).trim()).filter(Boolean).slice(-2).join(" | ") : "");
517
+ if (!String(res.final || "").trim() && modelTrouble) {
518
+ throw new Error("classify model call failed: " + modelTrouble.slice(0, 300));
519
+ }
520
+ let plan;
521
+ try {
522
+ const tasks = bindClassifiedTasks(request,extractTasks(res.final || ""));
523
+ plan = buildBlocksFromTasks(tasks, hints);
524
+ if (!plan || !plan.length) throw new Error("plan.blocks empty");
525
+ } catch (e2) {
526
+ if (/no JSON in classify output|no tasks array|plan\.blocks empty/.test(e2 && e2.message ? e2.message : String(e2))) seenNoJson = true;
527
+ if (i === attempts - 1) throw e2;
528
+ lastErr = e2 && e2.message ? e2.message : String(e2);
529
+ continue;
530
+ }
531
+ if (plan && plan.length) { saveDecompose(request, plan, hints); return finalize(plan); }
532
+ lastErr = lastErr || "(extracted empty plan)";
533
+ if (i === attempts - 1) throw new Error(lastErr);
534
+ } catch (e) {
535
+ if(/Unknown dependency|Self dependency|Cyclic dependencies|Ambiguous block identity/.test(e.message||""))throw e;
536
+ lastErr = e && e.message ? e.message : String(e);
537
+ }
538
+ }
539
+ console.warn("[orchestrator] decompose LLM failed after " + attempts + " attempt(s): " + lastErr +
540
+ (seenNoJson ? " (no valid JSON)" : "") + " -> fallback to single serial block (autosafe detect).");
541
+ return finalize(fallbackPlan(request).blocks);
542
+ }
543
+ function extractTasks(raw) {
544
+ const s = String(raw || "");
545
+ const stripped = s.replace(/```(?:json)?/gi, "").trim();
546
+ let parsed;
547
+ try {
548
+ const m = stripped.match(/\{[\s\S]*\}/);
549
+ parsed = JSON.parse(m ? m[0] : stripped);
550
+ } catch (e) {
551
+ throw new Error("no JSON in classify output: " + s.slice(0, 300));
552
+ }
553
+ if (Array.isArray(parsed)) return parsed;
554
+ if (parsed && Array.isArray(parsed.tasks)) return parsed.tasks;
555
+ throw new Error("no tasks array in classify output");
556
+ }
557
+ /**
558
+ * 【确定性拆块 · 面向 open-code-review 方向】不依赖 LLM,按请求里显式提及的项目目录
559
+ * (build/80、build/81、build/82、build/83,或任意 build/<N>)把任务拆成独立块,秒级返回。
560
+ * 用于「按项目/目录并行」这类高确定性的场景,避免 LLM 分类慢/不稳。
561
+ * 若请求未明确提及多个项目目录,返回 null(交给 LLM 拆块)。
562
+ */
563
+ /**
564
+ * Hard-split: any "independent" block that carries multiple tasks is flattened into one
565
+ * block per task (so each independent task gets its OWN window and runs in parallel).
566
+ * Only a block typed "linked" may keep multiple tasks (those run serialized in ONE window).
567
+ * This enforces "don't over-merge independent work" even if the decompose model mis-bundles.
568
+ */
569
+ // ---------------------------------------------------------------------------
570
+ // Decompose cache: reuse identical-request block plans INDEFINITELY.
571
+ // It NEVER auto-expires (no year rollover / TTL); only `--clear-cache` (or
572
+ // deleting the cache file) resets it, so ANY user who installs this
573
+ // orchestrator gets "the more you use it, the faster it gets" without any
574
+ // hardcoded project paths. Deterministic safety heuristic below is generic
575
+ // (any mention of multiple distinct project-like dirs), not specific to build/80|81|82|83.
576
+ // ---------------------------------------------------------------------------
577
+ const DECOMPOSE_CACHE = path.join(STATE_DIR, "orchestrator-decompose-cache.json");
578
+ // ---------------------------------------------------------------------------
579
+ // 调用方自助通道:计划文件(caller-supplied plan)。零 LLM:不读/不写拆块缓存、不调用任何模型。
580
+ // 两种 schema:{tasks:[...]}(等价 LLM 分类输出,随后走同一确定性分块引擎);
581
+ // {blocks:[...]}(等价 --plan 的 blocks 输出)。两者都受 ledgerPlan 硬校验(覆盖率/依赖/回退串行)。
582
+ // ---------------------------------------------------------------------------
583
+ function loadPlanFile(file) {
584
+ const resolved = path.resolve(String(file));
585
+ let raw;
586
+ try { raw = readFileSync(resolved, 'utf8'); }
587
+ catch (error) { throw new Error('PLAN_FILE_UNREADABLE: ' + resolved + ' (' + (error?.message || String(error)) + ')'); }
588
+ let doc;
589
+ try { doc = JSON.parse(String(raw).replace(/^\uFEFF/, '')); }
590
+ catch (error) { throw new Error('PLAN_FILE_INVALID: not valid JSON (' + (error?.message || String(error)) + ')'); }
591
+ const blocks = Array.isArray(doc?.blocks) ? doc.blocks : null;
592
+ const tasks = Array.isArray(doc?.tasks) ? doc.tasks : null;
593
+ if (blocks && tasks) throw new Error('PLAN_FILE_INVALID: provide either blocks or tasks, not both');
594
+ if (!blocks && !tasks) throw new Error('PLAN_FILE_INVALID: expected {tasks:[...]} or {blocks:[...]}');
595
+ if ((blocks && !blocks.length) || (tasks && !tasks.length)) throw new Error('PLAN_FILE_INVALID: the supplied list is empty');
596
+ return { tasks, blocks };
597
+ }
598
+ function planFromFile(request, doc, capacity, hints = resolveHints()) {
599
+ const blocks = doc.tasks
600
+ ? buildBlocksFromTasks(bindClassifiedTasks(request, doc.tasks), hints)
601
+ : structuredClone(doc.blocks);
602
+ if (!blocks.length) throw new Error('PLAN_FILE_INVALID: no blocks after deterministic grouping');
603
+ return capPlanBlocks(normalizePlan(blocks), capacity);
604
+ }
605
+ function ledgerEffectivePlan(request, candidate, capacity) {
606
+ const effective = ledgerPlan(request, candidate, capacity, capPlanBlocks).plan;
607
+ if (effective.length === 1 && effective[0].title === 'complete-inventory') {
608
+ console.warn('[orchestrator] plan file failed coverage validation; falling back to a single serial block (complete-inventory)');
609
+ }
610
+ return effective;
611
+ }
612
+
613
+ function buildBlockPrompt(body, tk) {
614
+ return String(body || "") +
615
+ "\n\n【TODO LIST 硬契约】开始前先列出本块完整 TODO LIST,逐项保留原始任务 ID;每项完成后立即更新状态并做现场验证,不得遗漏、跳过或凭口头汇报替代检查点。" +
616
+ "\n【本块唯一标识】" + tk + "\n(执行结束判定 = 本窗完成实际操作并现场验证后,**最后只输出一行结束标记 `__ORCH_DONE__ " + tk + "`**,不做任何汇报/多余输出;输出后等待被 watchdog 关闭。主控会关窗后按本任务提示词核对真实落盘状态。)\n";
617
+ }
618
+
619
+ function hintsKey(hints = {}) {
620
+ const norm = {
621
+ moduleToProj: (hints && hints.moduleToProj) || {},
622
+ foldRules: (hints && hints.foldRules) || {},
623
+ tailKeywords: Array.isArray(hints && hints.tailKeywords) ? hints.tailKeywords : [],
624
+ };
625
+ return JSON.stringify(norm);
626
+ }
627
+ function decomposeHash(request, hints = {}) {
628
+ return crypto.createHash("sha1").update(CORE_VERSION + "|" + String(request || "") + "|" + hintsKey(hints)).digest("hex");
629
+ }
630
+ function loadDecomposeCache() {
631
+ try {
632
+ if (existsSync(DECOMPOSE_CACHE)) {
633
+ const j = JSON.parse(readFileSync(DECOMPOSE_CACHE, "utf8").replace(/^\uFEFF/, ""));
634
+ if (j && j.version === CORE_VERSION && j.entries && typeof j.entries === "object") return j; // 永不自动失效(用户 --clear-cache 才清)
635
+ }
636
+ } catch { /* corrupted -> rebuild */ }
637
+ return { version: CORE_VERSION, entries: {} };
638
+ }
639
+ function saveDecomposeCache(obj) {
640
+ try { mkdirSync(STATE_DIR, { recursive: true }); writeFileSync(DECOMPOSE_CACHE, JSON.stringify(obj), "utf8"); } catch { /* best effort */ }
641
+ }
642
+ function loadCachedDecompose(request, hints) {
643
+ const h = decomposeHash(request, hints);
644
+ const j = loadDecomposeCache();
645
+ const e = j.entries[h];
646
+ if (!e) return null;
647
+ return e;
648
+ }
649
+ function saveDecompose(request, plan, hints) {
650
+ withRegistryLock(()=>{
651
+ const h = decomposeHash(request, hints);
652
+ const j = loadDecomposeCache();
653
+ j.entries[h] = { createdAt: Date.now(), blocks: plan };
654
+ saveDecomposeCache(j);
655
+ });
656
+ }
657
+ // Generic deterministic aid (NOT hardcoded to build/80|81|82|83): if the request
658
+ // names several distinct existing project-like dirs, split them into independent
659
+ // windows. Returns null when ambiguous, so the LLM handles the rest.
660
+ function genericProjectSplit(request) {
661
+ // 保守、确定性拆分(权威修复):仅当请求能被可靠切成"每个编号各有一个专属句子"时才拆,
662
+ // 且每个块 prompt 只针对该项目(绝不整段重复)。混合/歧义请求返回 null 交给 LLM 语义拆块。
663
+ const dirs = new Set();
664
+ const re = /(?:^|[\s,/\\])(?:build|proj|project|app|src|workspace|web|site|game)[\s/\\]+(\d{1,3})(?=\D|$)/gi;
665
+ let m;
666
+ while ((m = re.exec(request))) { const n = m[1]; if (n) dirs.add("proj-" + n); }
667
+ // fallback: bare standalone 2-digit project numbers (e.g. "给80的前端", "81登录页")
668
+ const bareRe = /(?<![0-9])(\d{2})(?![0-9])/g;
669
+ while ((m = bareRe.exec(request))) { const n = m[1]; if (n) dirs.add("proj-" + n); }
670
+ if (dirs.size < 2) return null;
671
+ const sentences = requestTasks(request).map(t=>t.prompt);
672
+ const ownByProj = {};
673
+ const misc = [];
674
+ let ambiguous = false;
675
+ for (const s of sentences) {
676
+ const hits = [];
677
+ for (const d of dirs) { const n = d.replace("proj-", ""); if (projectRef(s, n)) hits.push(d); }
678
+ if (hits.length === 1) (ownByProj[hits[0]] = ownByProj[hits[0]] || []).push(s);
679
+ else if (hits.length > 1) ambiguous = true;
680
+ else misc.push(s);
681
+ }
682
+ const allOwn = [...dirs].every((d) => (ownByProj[d] && ownByProj[d].length > 0));
683
+ // 保守(权威修复):一旦存在无法归属到某个具体项目编号的句子(如"给会员管理…做分页"这种跨项目续句),
684
+ // 确定性拆分无法安全归类——若硬把它当成独立 task-* 并行块,会与它真实所属项目竞态/改同一批文件。
685
+ // 此时整体交给 LLM 拆块:LLM 能读上下文,把它归到正确项目,同时仍会把真正独立的工具任务(openclaw/theme-repo/visualize 等)单独成块。
686
+ if (!allOwn || ambiguous || misc.length > 0) return null;
687
+ const blocks = [...dirs].map((title, i) => ({
688
+ type: "independent", title, dependsOn: [],
689
+ conflictKeys: ["dir:" + title],
690
+ tasks: [{ id: "t" + (i + 1), title, prompt: "仅处理项目 " + title + "。任务:" + ownByProj[title].join(";") + "。不要处理其它项目编号。" }],
691
+ }));
692
+ return blocks.length ? { blocks } : null;
693
+ }
694
+ function projectRef(s, n) {
695
+ // 无正则的确定匹配:检查句子是否含 "build\N" / "build/N" / 关键词N,避免转义坑
696
+ return s.indexOf("build\\" + n) !== -1 || s.indexOf("build/" + n) !== -1 || new RegExp("(?<![0-9])" + n + "(?![0-9])", "i").test(s);
697
+ }
698
+ // 通用单任务快速判定(不写死 build/N):无"多动作/多目标"标记、且命中明确动作词 -> 生成单一独立块。
699
+ function trySingleBlock(request) {
700
+ const text = String(request || "").trim();
701
+ if (!text) return null;
702
+ // 多动作/多目标/并列连词/分号/列举 -> 保守:一律交给 LLM/genericProjectSplit(少拆不误拆)。
703
+ if (/(和|同时|还|以及|另外|一并|分别|然后|随后|再|并|与|或|、|;|,|,)/.test(text)) return null;
704
+ if ((text.match(/。/g) || []).length > 1) return null; // 多个句子视为多任务
705
+ // 项目目录计数:>1 交 LLM;=1 且单动作 -> 单块指向该目录。
706
+ const dirRe = /(?:build|proj|project|app|src|workspace|web|site|game)[\s/\\]+(\d{1,3})/gi;
707
+ const dirSet = new Set(); let mm; while ((mm = dirRe.exec(text))) dirSet.add("proj-" + mm[1]);
708
+ if (dirSet.size > 1) return null;
709
+ const actionRe = /(?:创建|写|改|删|复制|启动|重启|生成|新增|修改|优化|修复|清理|列出|检查|删掉|建立|添加)/;
710
+ if (!actionRe.test(text)) return null;
711
+ if (dirSet.size === 1) {
712
+ const d = [...dirSet][0];
713
+ return { blocks: [{ type: "independent", title: d, dependsOn: [], conflictKeys: ["dir:" + d], tasks: [{ id: "t1", title: d, prompt: text }] }] };
714
+ }
715
+ return { blocks: [{ type: "independent", title: "single-task", dependsOn: [], conflictKeys: [], tasks: [{ id: "t1", title: "single-task", prompt: text }] }] };
716
+ }
717
+ function splitSentences(request) {
718
+ // 分句含全角/半角逗号:逗号常分隔多个并列目标(如 "修复81的空白,优化82的UI" 是两个独立项目)。
719
+ // 若逗号后是不带项目号的续句,会在 genericProjectSplit 落入 misc -> 整体交 LLM,仍安全(少拆不误拆)。
720
+ return String(request).split(/[。;;,,\n]+/).map((s) => s.trim()).filter(Boolean);
721
+ }
722
+ async function runThreadPrompt(prompt, opts = {}) {
723
+ if(AGENT.agent==='openclaw')return runOpenClawPlanner(AGENT.config,prompt,WORKSPACE);
724
+ if(AGENT.agent==='pi')return runPiPlanner(AGENT.config,prompt,WORKSPACE);
725
+ if(AGENT.agent==='opencode')return runOpenCodePlanner(AGENT.config,prompt,WORKSPACE);
726
+ if(AGENT.agent==='kimi')return runKimiPlanner(AGENT.config,prompt,WORKSPACE);
727
+ if(AGENT.agent==='claude')return runClaudePlanner(AGENT.config,prompt,WORKSPACE);
728
+ if(AGENT.config.unit)return runUnitPlanner(AGENT.config,prompt,WORKSPACE);
729
+ if(AGENT.config.profile)return runProfilePlanner(AGENT.config,prompt,WORKSPACE);
730
+ // outputSchema 走 runStreamed 的 turn 选项(强制结构化输出 / 纯 JSON),不能混进 startThread 配置。
731
+ const { outputSchema, ...threadOpts } = opts;
732
+ const thread = codex.startThread({ ...threadBase(), ...threadOpts });
733
+ const ac = new AbortController();
734
+ const turnOpts = { signal: ac.signal };
735
+ if (outputSchema) turnOpts.outputSchema = outputSchema;
736
+ const { events } = await thread.runStreamed(prompt, turnOpts);
737
+ const ORCH_DECOMPOSE_TIMEOUT_MS = Number(process.env.ORCH_DECOMPOSE_TIMEOUT_MS || 60000);
738
+ const abortTimer = setTimeout(() => { try { ac.abort(); } catch {} }, ORCH_DECOMPOSE_TIMEOUT_MS);
739
+ let final = "";
740
+ let errored = null;
741
+ const warnings = [];
742
+ let turnDone = false;
743
+ // The SDK streams raw JSONL events from `codex exec` as {type:"event_msg",payload:{type:...}}; it
744
+ // also accepts an AbortSignal. Two facts:
745
+ // 1. `codex exec` can exit non-zero (e.g. "Reading prompt from stdin", exit -1) on a flaky call —
746
+ // treat that as a failed attempt so the orchestrator RETRIES it instead of crashing the batch.
747
+ // 2. The RECURRING "codex-sdk stuck" bug: `codex exec` FINISHES normally (session rollout gets
748
+ // `task_complete`) but the async generator NEVER closes, so a naive `for await` hangs forever and
749
+ // --run-windows stalls at the decompose/execute step. We therefore read until the terminal event
750
+ // (`task_complete` / `turn.completed` / `turn.failed` / `error`), capture the final message, then
751
+ // `break` + abort to close the generator immediately (no idle stall tax).
752
+ const textOf = (item) => {
753
+ if (!item) return "";
754
+ if (typeof item.text === "string") return item.text;
755
+ if (typeof item.reasoning === "string") return item.reasoning;
756
+ if (Array.isArray(item.content)) return item.content.map((c) => (c && (c.text || c.text_delta || c.reasoning_text || c.reasoning_content)) || "").join("");
757
+ return "";
758
+ };
759
+ let lastReasoning = ""; // GLM 会把思考写进 reasoning_content、正文留空;作为兜底
760
+ const startedAt = Date.now();
761
+ try {
762
+ for await (const ev of events) {
763
+ // Absolute safety cap (never hang forever), used only if no terminal event arrives.
764
+ if (Date.now() - startedAt > (Number(process.env.ORCH_STREAM_CAP_MS) || 600000)) { errored = errored || "thread absolute timeout"; ac.abort(); clearTimeout(abortTimer); break; }
765
+ const p = ev.payload || {};
766
+ const ptype = p.type;
767
+ const normType = ev.type;
768
+ const item = ev.item || p.item;
769
+ // Terminal: the turn is genuinely finished. Capture the final message, then close the stream.
770
+ if (
771
+ ptype === "task_complete" || ptype === "turn_failed" || ptype === "error" ||
772
+ normType === "turn.completed" || normType === "turn.failed" || normType === "error"
773
+ ) {
774
+ if (p.last_agent_message && typeof p.last_agent_message === "string" && p.last_agent_message) final = p.last_agent_message;
775
+ if (!final && lastReasoning) final = lastReasoning; // content 优先、空则读 reasoning
776
+ if (normType === "turn.failed" || ptype === "turn_failed") errored = (ev.error && ev.error.message) || p.error_message || p.error || "turn failed";
777
+ else if (normType === "error" || ptype === "error") errored = ev.message || p.message || "error";
778
+ turnDone = true;
779
+ ac.abort(); // close the generator now — do not wait for it to finish on its own
780
+ clearTimeout(abortTimer); // 修象: 不在正常终止路径泄漏
781
+ break;
782
+ }
783
+ if (ptype === "item_completed" || normType === "item.completed") {
784
+ if (item) {
785
+ if (item.type === "agent_message" || item.type === "AgentMessage") {
786
+ const t = textOf(item) || textOf(p);
787
+ if (t) final = t.trim();
788
+ } else if (item.type === "reasoning" || item.type === "Reasoning") {
789
+ const rt = textOf(item) || textOf(p);
790
+ if (rt) lastReasoning = rt.trim();
791
+ } else if (item.type === "command_execution" && (item.status === "failed" || item.status === "error")) {
792
+ warnings.push("cmd exit " + item.exit_code + ": " + String(item.command || "").slice(0, 120));
793
+ } else if (item.type === "file_change" && (item.status === "failed" || item.status === "error")) warnings.push("patch failed");
794
+ }
795
+ }
796
+ else if (ptype === "reasoning" || normType === "reasoning" || p.type === "reasoning" || (item && (item.type === "reasoning" || item.type === "Reasoning"))) {
797
+ const rt = textOf(item) || textOf(p);
798
+ if (rt) lastReasoning = rt.trim();
799
+ }
800
+ }
801
+ } catch (e) {
802
+ clearTimeout(abortTimer);
803
+ const aborted = e && (e.name === "AbortError" || /abort/i.test(String(e.message || "")));
804
+ // If we completed via task_complete/turn then an abort is the intended close, not an error.
805
+ if (!aborted || !turnDone) errored = "codex exec failed (" + (e && e.message ? e.message : String(e)) + ")";
806
+ }
807
+ if (!errored && !final) errored = warnings.length ? warnings[warnings.length - 1] : "(no output)";
808
+ return { final, errored, warnings };
809
+ }
810
+ function trunc(s, n) {
811
+ s = String(s || "");
812
+ return s.length > n ? s.slice(0, n) + "..." : s;
813
+ }
814
+ function titleOf(key, title) {
815
+ // 可读 ASCII 标题:保留英数/常见符号,中文与其它非ASCII统一转成 -(避免 conhost 标题乱码)
816
+ const a = String(title || "").replace(/[^A-Za-z0-9_.\-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
817
+ return key + ":" + (a || "task");
818
+ }
819
+ function safe(t) {
820
+ return String(t).replace(/[^A-Za-z0-9_-]/g, "_") || "codex-task";
821
+ }
822
+ function blockBody(block) {
823
+ const body = block.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join("\n");
824
+ // 防御:无论 block.type 为何,只要一个窗里塞了多个任务,就绝不能说“互相独立/并行”——多任务在同一窗内必然串行。
825
+ // 曾有独立型块携带两个有先后依赖的任务(写 s.txt → 读它追加 z.txt),却因 type 标成 independent 而被 blockBody
826
+ // 输出“互不依赖、并行执行”,误导窗内 agent 把有依赖的任务当独立处理、漏做第二步,导致落盘缺失但 status=done。
827
+ // 修正:>1 任务的块一律按串行说明,杜绝“并行”误导。
828
+ const multiTask = block.tasks.length > 1;
829
+ return (
830
+ (block.type === "linked" || multiTask
831
+ ? "\n上面任务共享同一资源/存在依赖,若并行会冲突,故必须串行:先做第1个,做完再做第2个,依次进行,绝不同时并行。全部完成再汇报。"
832
+ : "\n上面任务之间互相独立、互不依赖:并行执行,互不等待。全部完成后汇总结果。") +
833
+ "\n\n【每个任务做完都必须现场自验】对每个改动:1) 读回创建/修改的文件,确认内容与编码(UTF-8 无 BOM);2) 跑一次回归/验证命令确认可用;3) 确认无残留临时文件、无中文乱码。**自验不过=该任务未完成**,如实说明失败点与现场,禁止以“做完了”掩盖。" +
834
+ "\n\n【执行纪律·禁止只计划不干活】本窗是授权真实执行窗,必须**在本窗内真正动手修改/执行**,逐项完成并自验,最后才输出以「【本块唯一标识】…完成」开头的汇报。**禁止**只输出“已定位/先读配置/先看日志/先确认归属/继续核查”这类计划、定位、核查性文字就当作完成;禁止停在“先…”。若确需先核查,也必须在同一窗内继续把改动做完、验证到位,不要只规划不落地。" +
835
+ "\n\n" + body
836
+ );
837
+ }
838
+ // ---- "原型/可行性检测"前置门禁(硬约束,已内嵌本 SDK,不依赖外部技能文件)----
839
+ // 每个真实块在 spawn 前做一次内嵌预检:prototype-validation 的"可行性"能力已并入本函数。
840
+ // 判定标准:本地执行块几乎都是用户明确要求做的真实改动,默认可行;仅当提示为空/无实际指令才判不可行,避免误拦"新建文件"类任务。
841
+ // 确定性复杂度分类:命中任一 -> "复杂",才可能走 LLM 原型。简单块永远不拦(防误拦建新目录等)。
842
+ function classifyBlockComplexity(prompt) {
843
+ const t = String(prompt || "");
844
+ if ((t.match(/。/g) || []).length > 1) return true; // 多句
845
+ if (/检查|修复|优化|重构|排查|为什么|怎么回事|调整|完善|推进|分析/.test(t)) return true; // 分析/决策
846
+ if (/根据|按照|结合|参照/.test(t)) return true; // 上下文依赖
847
+ if (t.length > 300) return true; // 长指令
848
+ return false;
849
+ }
850
+ // 可行性门禁(L1 空判):复杂/可行性判断已由拆块 LLM 定性(feasible/already_done)承担,
851
+ // 这里只保留最后一道确定性 sanity 空判,不再为每块单独调一次 LLM(避免重复往返)。
852
+ async function runPrototypeGate(title, prompt) {
853
+ const text = String(prompt || "").trim();
854
+ if (!text) { console.log(" preflight FAILED for [" + title + "] (empty/无动作): reject."); return false; }
855
+ console.log(" preflight PASSED for [" + title + "] (feasibility already resolved at decompose)");
856
+ return true;
857
+ }
858
+ // Enforcement: once the orchestrator is engaged, mark that real executions are gated. The
859
+ // sanction engine is --run-windows (a real, visible agent TUI window). headless --run/--exec
860
+ // is additionally hard-gated inside this file (needs ORCH_ALLOW_HEADLESS=1). open-tui-orchestrator-force.mjs reads
861
+ // this marker: from a NON-window context it refuses real-execution kinds and tells the caller to
862
+ // route through --run-windows. A sanctioned window sets $env:ORCH_WINDOW=1 (in its launcher)
863
+ // so it passes the guard.
864
+ const ORCH_ARMED = path.join(STATE_DIR, ".orchestrator-enabled");
865
+ function armEnforcement() {
866
+ try { mkdirSync(STATE_DIR, { recursive: true }); writeFileSync(ORCH_ARMED, String(Date.now()), "utf8"); } catch { /* ignore */ }
867
+ }
868
+ // ---- CLI 环境检测与安装(硬约束:先确保 codex CLI 可用,再谈后续)----
869
+ // 检测 codex 可执行;找不到则用 node22 的 npm 全局安装 @openai/codex;仍不行则报错中止。
870
+ function ensureCodexCli() { return AGENT.config.bin; }
871
+ // ---------------------------------------------------------------------------
872
+ // Superpowers 插件检测(用户要求:运行环境检测里加入对 Superpowers plugin 的检测)。
873
+ // 若 Superpowers 可正常使用 -> 让 Superpowers 参与到后续流程(并发分析 + 并发干活,强强联合);
874
+ // 若未检测到 -> 一切照旧(不因缺少它而降级/报错)。只读探测,绝不安装/改动 Superpowers。
875
+ // 判定标准:插件缓存目录存在 + 其 using-superpowers SKILL.md 存在 + 该插件在 config.toml 被启用。
876
+ // ---------------------------------------------------------------------------
877
+ // 通用化:superpowers 缓存目录默认在 CODEX_HOME 下;允许外部用 ORCH_SUPERPOWERS_CACHE_DIR 覆盖,适配不同宿主/插件市场。
878
+ const SUPERPOWERS_CACHE_DIR = process.env.ORCH_SUPERPOWERS_CACHE_DIR || path.join(CODEX_HOME, "plugins", "cache", "openai-api-curated", "superpowers");
879
+ function configPluginEnabled(name) {
880
+ try {
881
+ const cfg = readFileSync(path.join(CODEX_HOME, "config.toml"), "utf8");
882
+ return cfg.includes('plugins."' + name + '"') && /\[plugins\."[^"]+"\]\s*\r?\nenabled\s*=\s*true/.test(cfg);
883
+ } catch { return false; }
884
+ }
885
+ function detectSuperpowers() {
886
+ try {
887
+ if (!existsSync(SUPERPOWERS_CACHE_DIR)) return { available: false, reason: "plugin cache dir absent" };
888
+ const skillMd = path.join(SUPERPOWERS_CACHE_DIR, "1e285826", "skills", "using-superpowers", "SKILL.md");
889
+ if (!existsSync(skillMd)) return { available: false, reason: "using-superpowers SKILL.md absent" };
890
+ if (!configPluginEnabled("superpowers@openai-api-curated")) return { available: false, reason: "superpowers plugin not enabled" };
891
+ return { available: true, reason: "superpowers usable" };
892
+ } catch (e) { return { available: false, reason: (e && e.message ? e.message : "unknown") }; }
893
+ }
894
+ // ---------------------------------------------------------------------------
895
+ // 初始环境检测:磁盘空间检查(与技术栈/CLI 同层)。工作区所在盘需容纳 TUI 窗临时文件与窗内产物。
896
+ // 低于 ORCH_MIN_FREE_MB(默认 12000MB≈12GB)告警;低于 ORCH_MIN_FREE_HARD_MB(默认 3600MB≈3.6GB)直接拒绝(fail-closed)。
897
+ // warnOnly=true:只告警、绝不拦截(供 --plan 等只读命令使用)。
898
+ function checkDiskSpace(opts = {}) {
899
+ const warnOnly = !!opts.warnOnly;
900
+ const wsPath = opts.workspace || WORKSPACE;
901
+ try {
902
+ const s = statfsSync(wsPath);
903
+ const freeMB = Math.round((s.bavail * s.bsize) / (1024 * 1024));
904
+ const minMB = Number(process.env.ORCH_MIN_FREE_MB || 12000);
905
+ let hardMB = Number(process.env.ORCH_MIN_FREE_HARD_MB || 3600);
906
+ if (!Number.isFinite(hardMB) || hardMB > minMB) {
907
+ if (Number.isFinite(hardMB) && hardMB > minMB) console.warn("[orchestrator] WARN: ORCH_MIN_FREE_HARD_MB (" + hardMB + ") > ORCH_MIN_FREE_MB (" + minMB + "); clamping hard floor to min.");
908
+ hardMB = minMB;
909
+ }
910
+ if (!Number.isFinite(freeMB) || freeMB < 0) throw new Error("unexpected statfs result");
911
+ if (freeMB < hardMB) {
912
+ if (warnOnly) {
913
+ console.warn("[orchestrator] WARN: workspace disk critically low (" + freeMB + "MB free < " + hardMB + "MB hard floor) at " + wsPath + "; --plan is read-only so still runs, but --run-windows would refuse.");
914
+ return true;
915
+ }
916
+ console.error("[orchestrator] FATAL: workspace disk too low (" + freeMB + "MB free < " + hardMB + "MB hard floor) at " + wsPath + "; refusing to run (fail-closed).");
917
+ return false;
918
+ }
919
+ if (freeMB < minMB) {
920
+ console.warn("[orchestrator] WARN: low disk space (" + freeMB + "MB free < " + minMB + "MB) at " + wsPath + "; TUI window/temp may fail.");
921
+ return true;
922
+ }
923
+ console.log("[orchestrator] disk: workspace " + wsPath + " free=" + freeMB + "MB (min " + minMB + "MB / hard " + hardMB + "MB)");
924
+ return true;
925
+ } catch (e) {
926
+ console.warn("[orchestrator] WARN: could not stat workspace disk (" + (e && e.message ? e.message : String(e)) + "); skipping space check.");
927
+ return true;
928
+ }
929
+ }
930
+ // ---------------------------------------------------------------------------
931
+ // Spawn mode: window (visible Windows Terminal carrier) vs headless (hidden
932
+ // carrier; the SAME launcher ps1 keeps writing pidf/rf/log, so identity,
933
+ // leases, recovery adoption and enforcement gates are untouched). Default
934
+ // headless; --mode window (or ORCH_SPAWN_MODE=window) opts back in to popups.
935
+ const SPAWN_MODE_ALIASES={headless:"headless",exec:"headless",window:"window"};
936
+ let SPAWN_MODE="headless";
937
+ function normalizeSpawnMode(value){const v=String(value||"").trim().toLowerCase();if(!v)return null;const m=SPAWN_MODE_ALIASES[v];if(!m)throw new Error("UNSUPPORTED_SPAWN_MODE: "+value+" (use headless|exec|window)");return m;}
938
+ // ---------------------------------------------------------------------------
939
+ // Visible window (REAL popup, via cmd /c start — reliably opens a new console)
940
+ // ---------------------------------------------------------------------------
941
+ // Trust configuration is owned by the user, never rewritten here.
942
+ function spawnVisibleWindow(lp, title) {
943
+ // Workspace trust remains under the host/user configuration.
944
+ // Prefer Windows Terminal: it natively supports IME/TSF composition, so the Codex
945
+ // TUI keeps accepting Microsoft 拼音/日语 IME switches (fixes "偶发失效"). Codex
946
+ // runs the same launcher (UTF-8 + chcp 65001), just hosted in a WT ConPTY.
947
+ if (TUI_MODE === "wt" && WT_EXE && wtAvailable()) {
948
+ // Unique window name is for terminal routing only; never kill its shared host process.
949
+ const bn = path.basename(String(lp || ""));
950
+ const winS = bn.replace(/^win-launch-/, "").replace(/\.ps1$/, "");
951
+ const windowName = "win-" + (winS || "x");
952
+ // 同步拉起(no-activate helper 经 spawnSync 执行完 CreateProcessW 再返回):实测异步 detached 路径
953
+ // 弹窗经常不出现("popup NOT confirmed" 直至超时),sync 路径稳定 1~2 秒内确认;阻塞 ~2.5s 换可靠性。
954
+ spawnInactiveWindow(WT_EXE, ["-w", windowName, PS_EXE, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", lp], WORKSPACE);
955
+ console.log("spawned visible " + AGENT.agent + " TUI (Windows Terminal): " + title + " (window=" + windowName + ")");
956
+ return;
957
+ }
958
+ // conhost fallback uses the same no-activate helper rather than cmd /c start,
959
+ // so a missing Windows Terminal does not reintroduce a focusing window.
960
+ // 与 WT 路径一致:sync 拉起保证窗口真正弹出(见上)。powershell 走 SystemRoot 锚定(防工作区二进制投毒)。
961
+ spawnInactiveWindow(PS_EXE,['-NoProfile','-ExecutionPolicy','Bypass','-File',lp],WORKSPACE);
962
+ console.log("spawned visible " + AGENT.agent + " TUI: " + title);
963
+ }
964
+ // 弹窗后精准检测:pid 文件存在 + launcher(WT pane 宿主)进程存活 => 窗口真弹出(见 windowGenuinelyPopped)。
965
+ // 失败重试(上限 maxRetry,默认 3),仍失败返回 false(由调用方标记 popup-failed 并汇报)。
966
+ function windowGenuinelyPopped(pidf, token) {
967
+ // 弹窗精准判定 = pid 文件存在 + launcher 进程真实存活(launcher 是 Windows Terminal pane 宿主进程,存活即窗口已弹出)。
968
+ // 不把 agent 子进程作为必选证据:agent 冷启动(模型 provider)可能很慢,若等它会把"已弹出"误判为未弹出而重复弹窗;agent 未起由回收超时兜底。
969
+ // 秒退单元兜底:超快/秒崩的单元可能在确认轮询间隙里已跑完退出——结果文件已落盘即视为已启动,
970
+ // 交给正常 wait 路径读取结果,避免把"已完成"误判成 popup-failed 并白白重试。
971
+ if (!pidf || !existsSync(pidf)) return false;
972
+ const pid = readPidFile(pidf);
973
+ if (!pid) return false;
974
+ if (pidAliveCheck(pid)) return true;
975
+ const rf = path.join(path.dirname(String(pidf)), path.basename(String(pidf)).replace(/^window-/, 'win-').replace(/\.pid$/, '.result.json'));
976
+ return existsSync(rf);
977
+ }
978
+ async function confirmWindowPopped(pidf, token) {
979
+ const deadline = Date.now() + 35000;
980
+ while (Date.now() < deadline) {
981
+ if (windowGenuinelyPopped(pidf, token)) return true;
982
+ await new Promise((r) => setTimeout(r, 500));
983
+ }
984
+ return false;
985
+ }
986
+ // 加固②:检测「launcher 下是否已出现 agent 子进程」。windowGenuinelyPopped 只证明 launcher(WT pane 宿主)活着,
987
+ // 但 codex TUI 冷启动(模型 provider)可能仍很慢——若父进程立刻去等 session rollout,会因 rollout 还没建而误判
988
+ // "no session rollout matched"。此函数在弹窗确认后继续短等 agent 子进程出现,让父进程的会话匹配不会起跑太早。
989
+ // 只读探测,绝不弹出/关闭;失败也不强制 (只是再等,绝不误判未弹出而重复弹窗)。
990
+ function agentDescendantAlive(pidf) {
991
+ try {
992
+ const pid = readPidFile(pidf);
993
+ if (!pid) return false;
994
+ const r = spawnSync(PS_EXE, ["-NoProfile", "-Command",
995
+ "$ErrorActionPreference='SilentlyContinue'; $a=@{}; $p=Get-CimInstance Win32_Process; foreach($pr in $p){ if($pr.ParentProcessId -eq 0){continue}; $a[$pr.ProcessId]=$pr.ParentProcessId }; $root=" + Number(pid) + "; $s=@($root); $i=0; while($i -lt $s.Count){ $cur=$s[$i]; $i++; foreach($pr in $p){ if($pr.ParentProcessId -eq $cur){ if(($pr.Name -match 'codex|node') -or ($pr.CommandLine -like '*codex*')){ Write-Output 1; exit 0 }; $s+=$pr.ProcessId } } }; Write-Output 0"],
996
+ { windowsHide: true, encoding: "utf8", timeout: 8000 });
997
+ return /^1/m.test(String(r.stdout || ""));
998
+ } catch { return false; }
999
+ }
1000
+ async function warmupWindow(pidf, token) {
1001
+ // 通用冷启动错峰:已确认 launcher 存活后,再短等 agent 子进程出现(避免父进程过早去等 session rollout 而漏匹配)。
1002
+ // 不特判任何 agent 的子进程形态(对 codex/claude/gemini/opencode 等主流 agent 通用);失败只继续等,绝不误判未弹出。
1003
+ const agentWarm = Date.now() + 15000;
1004
+ while (Date.now() < agentWarm) {
1005
+ if (agentDescendantAlive(pidf)) break;
1006
+ await new Promise((r) => setTimeout(r, 400));
1007
+ }
1008
+ await new Promise((r) => setTimeout(r, 800));
1009
+ }
1010
+ async function spawnWindowWithRetry(lp, title, pidf, maxRetry = 3, token, reservedByRunner = false) {
1011
+ const auxiliaryId="aux-"+path.basename(lp); let coordinator;
1012
+ if(!reservedByRunner){coordinator=coordinatorDir(WORKSPACE);const auxCap=Math.max(0,Number(process.env.ORCH_MAX_WINDOWS||0)||0);const claim=reserve(coordinator,{id:auxiliaryId,keys:["aux:"+token]},auxCap?Math.min(computeMaxWindows(os.totalmem()/1024**3),auxCap):computeMaxWindows(os.totalmem()/1024**3));if(!claim.ok)throw new Error("Window admission waiting: "+claim.reason);}
1013
+ // 恢复调用方传入的重试上限(默认 3):sync 拉起后每次尝试 ~2.5s,重试成本可控,兜底 WT 偶发不弹窗。
1014
+ for (let attempt = 1; attempt <= maxRetry; attempt++) {
1015
+ spawnVisibleWindow(lp, title);
1016
+ const deadline = Date.now() + 35000;
1017
+ let ok = false;
1018
+ const t0 = Date.now();
1019
+ while (Date.now() < deadline) {
1020
+ if (windowGenuinelyPopped(pidf, token)) { ok = true; break; }
1021
+ await new Promise((r) => setTimeout(r, 500));
1022
+ }
1023
+ if (ok) {
1024
+ // 加固②:弹窗确认后再等 agent 子进程真正拉起(冷启动),确保父进程随后去等 session rollout 时不会起跑太早。
1025
+ await warmupWindow(pidf, token);
1026
+ if(!reservedByRunner)bind(coordinator,auxiliaryId,launcherIdentity(readPidFile(pidf),pidf));
1027
+ if (attempt > 1 || Date.now() - t0 > 6500) console.log(" [window] popup CONFIRMED (pidf+launcher+" + AGENT.agent + " child) for [" + title + "]");
1028
+ return true;
1029
+ }
1030
+ console.log(" [window] popup NOT confirmed (attempt " + attempt + "/" + maxRetry + ") for [" + title + "]");
1031
+ }
1032
+ console.log(" !! [window] popup FAILED after " + maxRetry + " attempts for [" + title + "]");
1033
+ return false;
1034
+ }
1035
+ // Headless carrier: same launcher contract, no visible window. The ps1 still
1036
+ // writes pidf/rf/log and exports ORCH_WINDOW=1, so launcherIdentity, leases,
1037
+ // recovery adoption, closeWindowByPid and the enforcement gates all behave
1038
+ // exactly as in window mode. windowsHide + no console window also means focus
1039
+ // can never be stolen.
1040
+ function spawnHeadlessUnit(lp, title) {
1041
+ const bn = path.basename(String(lp || ""));
1042
+ const unitName = bn.replace(/^win-launch-/, "").replace(/\.ps1$/, "");
1043
+ // 载体分级:TUI 类 agent(画像声明 carrier=pty)走 ConPTY 伪终端宿主——真实控制台、完全不可见;
1044
+ // print 类保持无控制台隐藏进程。同一条启动器/身份/租约/恢复契约,两条载体可互换。
1045
+ if (assertUnitCarrierReady() === "pty") {
1046
+ const host = spawn(process.execPath, [fileURLToPath(new URL('./pty-host.mjs', import.meta.url)), String(lp)], { cwd: WORKSPACE, env: process.env, windowsHide: true, stdio: "ignore" });
1047
+ host.unref();
1048
+ console.log("spawned headless " + AGENT.agent + " unit via PTY carrier: " + title + " (unit=" + unitName + ")");
1049
+ return;
1050
+ }
1051
+ const child = spawn(PS_EXE, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", lp], { cwd: WORKSPACE, env: process.env, windowsHide: true, stdio: "ignore" });
1052
+ child.unref();
1053
+ console.log("spawned headless " + AGENT.agent + " unit: " + title + " (unit=" + unitName + ")");
1054
+ }
1055
+ async function spawnHeadlessWithRetry(lp, title, pidf, maxRetry = 3, token, reservedByRunner = false) {
1056
+ const auxiliaryId="aux-"+path.basename(lp); let coordinator;
1057
+ if(!reservedByRunner){coordinator=coordinatorDir(WORKSPACE);const auxCap=Math.max(0,Number(process.env.ORCH_MAX_WINDOWS||0)||0);const claim=reserve(coordinator,{id:auxiliaryId,keys:["aux:"+token]},auxCap?Math.min(computeMaxWindows(os.totalmem()/1024**3),auxCap):computeMaxWindows(os.totalmem()/1024**3));if(!claim.ok)throw new Error("Window admission waiting: "+claim.reason);}
1058
+ // Hidden spawn is deterministic (no OS window manager in the loop), so a
1059
+ // single attempt usually confirms; keep the retry budget for cold-start
1060
+ // hiccups and parity with the window path.
1061
+ for (let attempt = 1; attempt <= maxRetry; attempt++) {
1062
+ spawnHeadlessUnit(lp, title);
1063
+ const deadline = Date.now() + 35000;
1064
+ let ok = false;
1065
+ const t0 = Date.now();
1066
+ while (Date.now() < deadline) {
1067
+ if (windowGenuinelyPopped(pidf, token)) { ok = true; break; }
1068
+ await new Promise((r) => setTimeout(r, 500));
1069
+ }
1070
+ if (ok) {
1071
+ await warmupWindow(pidf, token);
1072
+ if(!reservedByRunner)bind(coordinator,auxiliaryId,launcherIdentity(readPidFile(pidf),pidf));
1073
+ if (attempt > 1 || Date.now() - t0 > 6500) console.log(" [headless] launch CONFIRMED (pidf+launcher alive) for [" + title + "]");
1074
+ return true;
1075
+ }
1076
+ console.log(" [headless] launch NOT confirmed (attempt " + attempt + "/" + maxRetry + ") for [" + title + "]");
1077
+ }
1078
+ console.log(" !! [headless] launch FAILED after " + maxRetry + " attempts for [" + title + "]");
1079
+ // PTY 载体的失败面在自己日志里(控制器读不到被隐藏的 stdout);补一段尾迹便于诊断。
1080
+ try {
1081
+ const ptyLog = path.join(path.dirname(String(pidf)), path.basename(String(lp)).replace(/\.ps1$/, '') + '.pty.log');
1082
+ if (existsSync(ptyLog)) {
1083
+ const tail = String(readFileSync(ptyLog, 'utf8')).replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ' ').replace(/\s+/g, ' ').trim().slice(-400);
1084
+ if (tail) console.log(" !! [headless] PTY log tail: " + tail);
1085
+ }
1086
+ } catch { /* diagnostics only */ }
1087
+ return false;
1088
+ }
1089
+ // 失败/未决块自动重试:窗已关但块未完成时,用全新 token 重新弹窗再跑,避免“提前结束轮询/留计划草稿”。
1090
+ // 返回 { status, exitCode, result };只有抓到本块自己的完成标记才算 done。
1091
+ async function retryBlock(rec, rid, auth, maxRetry, closeDelayMs) {
1092
+ for (let attempt = 1; attempt <= maxRetry; attempt++) {
1093
+ const tk = rec.key + "-" + rid + "-" + Math.random().toString(36).slice(2, 8);
1094
+ const body = auth + buildBlockPrompt(blockBody(rec.b), tk);
1095
+ const { lp, pidf, pf, rf } = writeInteractiveLauncher(rec.key, body, rid);
1096
+ // 重试窗同样走原子占位:让其它编排进程能看见/锁定它,也保证本窗满足 enforcement 的“活注册窗”判定。
1097
+ const claim = { id: "claim-" + rid + "-" + rec.key + "-" + Math.random().toString(36).slice(2, 8), title: rec.title, keys: rec.keys, pidFile: pidf, pid: 0, locked: false, pending: true, reservedAt: Date.now() };
1098
+ const claimBlocker = tryReserveWindow(claim);
1099
+ if (claimBlocker) {
1100
+ console.log(" !! [retry " + rec.key + " att" + attempt + "] atomic claim blocked by [" + claimBlocker + "].");
1101
+ for (const f of [pf, lp]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
1102
+ continue;
1103
+ }
1104
+ const popped = await (SPAWN_MODE==="headless"?spawnHeadlessWithRetry:spawnWindowWithRetry)(lp, rec.title, pidf, 3, tk);
1105
+ if (!popped) {
1106
+ cancelReservation(claim.id);
1107
+ for (const f of [pf, lp, rf, pidf, pidf + ".self-closed", rf + ".self-closed"]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
1108
+ console.log(" !! [retry " + rec.key + " att" + attempt + "] popup failed; reservation cancelled.");
1109
+ continue;
1110
+ }
1111
+ commitReservation(claim.id, readPidFile(pidf) || 0);
1112
+ const matchKey = "【本块唯一标识】" + tk;
1113
+ const sf = await waitForSessionFile(matchKey, 90000, rf);
1114
+ let res = { done: false, text: "" };
1115
+ if (sf) res = await waitTaskComplete(sf, POLL_TIMEOUT_MS, rf, matchKey, pidf);
1116
+ else if (existsSync(rf)) { const c = readExitCode(rf); res = { done: true, text: "(launcher exited code " + (c === null ? "?" : c) + "; no session rollout matched)", exitCode: c }; }
1117
+ // 执行结束判定(用户方案:done=做完了,不等同于成功):res.done 即收,是否达标由主控核对真实落盘状态。
1118
+ let text = res.text || "";
1119
+ if (!text || /no session rollout matched|no result captured|launcher exited/.test(text)) {
1120
+ try { if (sf) text = extractResult(sf).text || ""; } catch { /* ignore */ }
1121
+ }
1122
+ if (res.done) {
1123
+ console.log(" [retry " + rec.key + " att" + attempt + "] execution ended -> closing");
1124
+ await new Promise((x) => setTimeout(x, closeDelayMs));
1125
+ await closeWindow(pidf);
1126
+ cancelReservation(claim.id);
1127
+ for (const f of [pf, lp, pidf, rf, pidf + ".self-closed", rf + ".self-closed"]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
1128
+ return { status: "done", exitCode: res.exitCode ?? null, result: (text && text.trim() ? text.trim() : "(窗内 codex 已执行结束并自动关闭;请按任务提示词核对真实落盘状态)").slice(0, 2000) };
1129
+ }
1130
+ console.log(" !! [retry " + rec.key + " att" + attempt + "] not own completion (text=" + String(text).slice(0, 50) + ")");
1131
+ // 未捕获到完成汇报也一律收窗并取消占位,杜绝“重试窗留在屏幕上/重复弹同键窗”的泄漏。
1132
+ await closeWindow(pidf);
1133
+ cancelReservation(claim.id);
1134
+ for (const f of [pf, lp, pidf, rf, pidf + ".self-closed", rf + ".self-closed"]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
1135
+ }
1136
+ return { status: "uncaptured", exitCode: null, result: "(重试后仍未捕获本块自己完成汇报)" };
1137
+ }
1138
+ // Interactive-TUI launcher. The interactive `codex "prompt"` window does not exit on its own; a
1139
+ // background watchdog in the launcher scans THIS window session rollout: as soon as the LAST assistant
1140
+ // message containing this block token + 完成 appears, it writes the strong `.self-closed` marker and
1141
+ // kills the full codex descendant tree (hit-to-close, no stability wait), so the launcher runs `exit 0` and the
1142
+ // pane auto-closes. `Write-Result` then uses `Get-LastReport` to persist the window last assistant output
1143
+ // into `win-<s>.result.json` (no token/完成 required), so even a report lacking the token is captured.
1144
+ // The launcher ALWAYS reaches `exit 0` (a non-zero exit is the "process exited, code X" stuck-pane bug).
1145
+ // Encoding + Windows Terminal hosting set here (see ORCH_TUI and the chcp 65001 + InputEncoding UTF-8) for CJK/IME.
1146
+
1147
+ // Shared PowerShell descendant-kill routine used by BOTH the launcher watchdog and closeWindowByPid:
1148
+ // snapshot Win32_Process once, then walk the ParentProcessId chain starting at the launcher ($root) to
1149
+ // collect EVERY descendant, and kill each PID individually (no reliance on taskkill /T reaching the
1150
+ // whole tree). The launcher itself is never in the walk's kill set; $PID (the watchdog's own job
1151
+ // process) is always excluded; when the list is empty (codex already exited on its own) nothing is
1152
+ // killed, which preserves the "never force-kill launcher / skip if codex exited" safety boundaries.
1153
+ function psKillDescendantsLines() {
1154
+ return [
1155
+ "$ErrorActionPreference = 'SilentlyContinue'",
1156
+ "$known=@{}; $cutoff=@{}; $total=0",
1157
+ "for($pass=0;$pass -lt 3;$pass++) {",
1158
+ "$all = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue",
1159
+ "if(-not $known.ContainsKey($root)){$known[$root]=$all|Where-Object ProcessId -eq $root}",
1160
+ "$victims = New-Object 'System.Collections.Generic.List[int]'",
1161
+ "$queue = New-Object 'System.Collections.Generic.Queue[int]'",
1162
+ "$seen = @{}",
1163
+ "$seen[$root] = $true",
1164
+ "foreach($k in @($known.Keys)){$queue.Enqueue([int]$k)}",
1165
+ "while ($queue.Count -gt 0) {",
1166
+ " $cur = $queue.Dequeue()",
1167
+ " foreach ($pr in $all) {",
1168
+ " if ($pr.ProcessId -ne $PID -and $pr.ParentProcessId -eq $cur -and -not $seen.ContainsKey($pr.ProcessId) -and $known[$cur] -and $pr.CreationDate -ge $known[$cur].CreationDate -and (-not $cutoff.ContainsKey($cur) -or $pr.CreationDate.ToUniversalTime() -le $cutoff[$cur])) {",
1169
+ " $seen[$pr.ProcessId] = $true",
1170
+ " $known[[int]$pr.ProcessId]=$pr",
1171
+ " $victims.Add($pr.ProcessId)",
1172
+ " $queue.Enqueue($pr.ProcessId)",
1173
+ " }",
1174
+ " }",
1175
+ "}",
1176
+ "$victims.Reverse()",
1177
+ "foreach ($v in $victims) { $before=$all | Where-Object ProcessId -eq $v; $now=Get-CimInstance Win32_Process -Filter ('ProcessId='+$v); if($now -and $now.CreationDate -eq $before.CreationDate -and $now.CommandLine -eq $before.CommandLine -and $now.Name -eq $before.Name){$cutoff[[int]$v]=[DateTime]::UtcNow;taskkill /PID $v /F 2>$null | Out-Null} }",
1178
+ "$total += $victims.Count",
1179
+ "}",
1180
+ "Write-Output $total",
1181
+ ];
1182
+ }
1183
+
1184
+ // computeMaxWindows 已下沉至 contracts.mjs(fresh 规划与 resume 校验共用),此处经 import 复用。
1185
+
1186
+ // Capacity is a planning constraint, not a queue of extra windows.
1187
+ export function capPlanBlocks(input, capacity) {
1188
+ if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('capacity must be a positive integer');
1189
+ const plan = structuredClone(input);
1190
+ levelWaves(plan.map((b,i)=>({key:`B${i+1}`,title:b.title,b,dependsOn:b.dependsOn||[]})));
1191
+ if (plan.length <= capacity) return plan;
1192
+ const aliases = new Map();
1193
+ plan.forEach((b, i) => { aliases.set(`B${i + 1}`, i); aliases.set(titleOf(`B${i + 1}`, b.title), i); aliases.set(b.title, i); });
1194
+ const deps = plan.map((b, i) => new Set((b.dependsOn || []).map(d => aliases.get(String(d))).filter(d => d !== undefined && d !== i)));
1195
+ const order = [], done = new Set();
1196
+ while (order.length < plan.length) {
1197
+ const next = plan.findIndex((_, i) => !done.has(i) && [...deps[i]].every(d => done.has(d)));
1198
+ if (next < 0) throw new Error('Cannot compact cyclic block dependencies; no tasks were executed');
1199
+ order.push(next); done.add(next);
1200
+ }
1201
+ // Consecutive topological ranges prevent merge-induced dependency cycles.
1202
+ const groups = Array.from({ length: capacity }, (_, i) => order.slice(
1203
+ Math.floor(i * order.length / capacity), Math.floor((i + 1) * order.length / capacity)));
1204
+ const owner = new Map();
1205
+ groups.forEach((g, i) => g.forEach(index => owner.set(index, i)));
1206
+ return groups.map((g, i) => ({
1207
+ type: g.length > 1 || g.some(index => plan[index].type === 'linked') ? 'linked' : 'independent',
1208
+ title: `capacity-${i + 1}`,
1209
+ tasks: g.flatMap(index => plan[index].tasks || []),
1210
+ conflictKeys: [...new Set(g.flatMap(index => blockKeys(plan[index])))],
1211
+ dependsOn: [...new Set(g.flatMap(index => [...deps[index]].map(d => owner.get(d))))]
1212
+ .filter(group => group !== i).map(group => `capacity-${group + 1}`),
1213
+ }));
1214
+ }
1215
+ // Shared by the real window scheduler and its capacity boundary tests.
1216
+ export function buildWindowWaves(recs, maxByMem) {
1217
+ if (!Number.isInteger(maxByMem) || maxByMem < 1) {
1218
+ throw new RangeError('maxByMem must be a positive integer');
1219
+ }
1220
+ if (recs.length > maxByMem) throw new RangeError('plan exceeds window capacity; compact before scheduling');
1221
+ return levelWaves(recs);
1222
+ }
1223
+ // 窗口触发策略:只有规划中真的存在同一波次的两个或更多可执行块,才打开 TUI 窗。
1224
+ // 单块和纯依赖链交回发起对话就地执行,避免为简单/串行任务弹出无意义的窗口。
1225
+ // 返回的 windowCount 是同一时刻需要的最大窗口数,不是任务总数;任务总量仍由原始账本保证。
1226
+ function windowExecutionPolicy(plan) {
1227
+ const blocks = Array.isArray(plan) ? plan : [];
1228
+ if (blocks.length <= 1) return { mode: 'inline', reason: 'single-block', windowCount: 0 };
1229
+ const recs = blocks.map((b, i) => ({
1230
+ key: 'B' + (i + 1),
1231
+ title: b?.title || 'block-' + (i + 1),
1232
+ b,
1233
+ dependsOn: Array.isArray(b?.dependsOn) ? b.dependsOn : [],
1234
+ }));
1235
+ const waves = levelWaves(recs);
1236
+ const maxWave = waves.reduce((max, wave) => Math.max(max, wave.length), 0);
1237
+ return maxWave >= 2
1238
+ ? { mode: 'windows', reason: 'parallel-wave', windowCount: maxWave }
1239
+ : { mode: 'inline', reason: 'serial-dependencies', windowCount: 0 };
1240
+ }
1241
+ // ---------------------------------------------------------------------------
1242
+ // 窗内 auth 提示词的确定性策略改写(纯函数,可单测):
1243
+ // TUI 渲染重叠修复:把「三遍检查」提示词里的 ①②③ 改成 1) 2) 3)(纯 ASCII 宽度),避免 Windows Terminal CJK 宽度歧义导致序号与文字重叠。
1244
+ // 说明:窗内不再使用子代理(用户明确:orchestrator 发起的 TUI 窗不再使用子代理),故不再替换子代理相关子句。
1245
+ export function applyAuthPromptPolicy(auth) {
1246
+ let s = String(auth || "");
1247
+ s = s.replace(
1248
+ "随后按此顺序自查:①先检查一遍这个 TODO LIST 是否完整/是否有遗漏或错误,发现问题就修复;②再检查一遍这个 TODO LIST 的**逻辑问题**(先后依赖、因果、边界、是否会冲突/漏步),发现问题就修复;③最后再检查一遍这个 TODO LIST 的问题(复查,确保①②都改彻底)。",
1249
+ "随后按此顺序自查:1) 先检查一遍这个 TODO LIST 是否完整/是否有遗漏或错误,发现问题就修复;2) 再检查一遍这个 TODO LIST 的**逻辑问题**(先后依赖、因果、边界、是否会冲突/漏步),发现问题就修复;3) 最后再检查一遍这个 TODO LIST 的问题(复查,确保 1) 2) 都改彻底)。"
1250
+ );
1251
+ return s;
1252
+ }
1253
+
1254
+ // 硬性要求(用户明确,必须遵守):对 orchestrator 自身的改动,必须先于并独立于所有 TUI 窗执行——
1255
+ // 先 inline 就地把 orchestrator 改完,再据此发起并发 TUI 窗。原因:TUI 窗依赖 orchestrator SDK;
1256
+ // 若把“改 SDK”交给一个 TUI 窗去做,该窗的 watchdog/关窗也依赖被改的 SDK,会出现“改它的窗自己关不掉”。
1257
+ // 故 --run-windows 检测到这类请求一律返回 inline(由发起对话就地改完再另发窗)。
1258
+ function isOrchestratorSelfMod(text) {
1259
+ const t = String(text || "");
1260
+ // 通用化(不假设特定 agent/语言):识别「改/修/升级 编排器自身 源码/脚本/进程/skill」这类自引用意图。
1261
+ // 覆盖英文 + 中文关键词,并对编排器自身的通用称谓(orchestrator/编排/调度/TUI 窗/skill)都命中;
1262
+ // 命中只是「可能改自己」的信号,进一步由调用方决定是否返回 inline(宁可多识、不误把改自己的活交给窗)。
1263
+ const selfRef =
1264
+ /open-tui-orchestrator|orchestrator|orchestrate|编排|调度器|调度窗|TUI 窗|TUI窗|并发编排/i.test(t) ||
1265
+ /scripts\/?orchestrate|orchestrate-sdk\.mjs|\.codex.*\/?orchestrat/i.test(t);
1266
+ if (!selfRef) return false;
1267
+ const modifyIntent =
1268
+ /(修复|修改|改|升级|加固|优化|调整|重构|补|加固|收紧|fix|edit|update|build|harden|refactor|improve|rework)/i.test(t);
1269
+ return modifyIntent;
1270
+ }
1271
+
1272
+ function writeInteractiveLauncher(title, prompt, sfx) {
1273
+ if(AGENT.agent==='pi')return writePiLauncher(AGENT.config,{key:title,prompt,suffix:sfx,workspace:WORKSPACE,temp:TMP});
1274
+ if(AGENT.agent==='openclaw')return writeOpenClawLauncher(AGENT.config,{key:title,prompt,suffix:sfx,workspace:WORKSPACE,temp:TMP});
1275
+ if(AGENT.agent==='opencode')return writeOpenCodeLauncher(AGENT.config,{key:title,prompt,suffix:sfx,workspace:WORKSPACE,temp:TMP});
1276
+ if(AGENT.agent==='kimi')return writeKimiLauncher(AGENT.config,{key:title,prompt,suffix:sfx,workspace:WORKSPACE,temp:TMP});
1277
+ if(AGENT.agent==='claude')return writeClaudeLauncher(AGENT.config,{key:title,prompt,suffix:sfx,workspace:WORKSPACE,temp:TMP});
1278
+ if(AGENT.config.unit)return writeUnitLauncher(AGENT.config,{key:title,prompt,suffix:sfx,workspace:WORKSPACE,temp:TMP});
1279
+ if(AGENT.config.profile)return writeProfileLauncher(AGENT.config,{key:title,prompt,suffix:sfx,workspace:WORKSPACE,temp:TMP});
1280
+ const s = safe(title) + (sfx ? "-" + sfx : "");
1281
+ mkdirSync(TMP, { recursive: true });
1282
+ const pf = path.join(TMP, `agent-win-${s}.md`);
1283
+ const lp = path.join(TMP, `win-launch-${s}.ps1`);
1284
+ const pidf = path.join(TMP, `window-${s}.pid`);
1285
+ const rf = path.join(TMP, `win-${s}.result.json`);
1286
+ // 加固:生成新窗启动脚本时清掉本块路径上可能残留的旧 result / .self-closed 标记,避免窗内 watchdog 把
1287
+ // “旧块已完成的残留标记”误当成“本窗 agent 已退出”,从而提前 exit 0 不再杀 codex,造成“窗口任务完成但
1288
+ // agent 仍存活、窗未自动关”的常驻窗。仅删本块自己的 result 产物,绝不触碰其它窗/活窗。
1289
+ try { if (existsSync(rf)) rmSync(rf, { force: true }); } catch { /* ignore */ }
1290
+ try { if (existsSync(rf + ".self-closed")) rmSync(rf + ".self-closed", { force: true }); } catch { /* ignore */ }
1291
+ writeFileSync(pf, prompt, "utf8");
1292
+ // Unique ASCII token embedded in every block prompt ("【本块唯一标识】<token>"): the watchdog uses it
1293
+ // to find THIS window's own session rollout (same token the orchestrator matches, ASCII-only so the
1294
+ // PowerShell match never has to deal with JSON-escaped CJK).
1295
+ // Only capture the REAL ASCII token (alnum/_/-) right after the marker. The block prompt/report
1296
+ // contract also contains boilerplate text like 「【本块唯一标识】…完成」开头 BEFORE the injected
1297
+ // unique token; the old pattern ([^\s\r\n】]+) matched that boilerplate first, stripped it to an
1298
+ // empty $token, which silently disabled the launcher's self-close watchdog (thence the stuck
1299
+ // "[进程已退出,代码为 1]" pane). Requiring an ASCII-leading token skips the CJK boilerplate and
1300
+ // lands on the true block token.
1301
+ const tokenMatch = String(prompt || "").match(/【本块唯一标识】\s*([A-Za-z0-9][A-Za-z0-9_\-]*)/);
1302
+ const token = tokenMatch ? String(tokenMatch[1]) : "";
1303
+ const BOM = "\uFEFF";
1304
+ // PowerShell 单引号字符串内嵌路径/变量:把 ' 转义成 '',避免路径含引号时破坏脚本/注入。
1305
+ const pidfQ = String(pidf || "").replace(/'/g, "''");
1306
+ const pfQ = String(pf || "").replace(/'/g, "''");
1307
+ const rfQ = String(rf || "").replace(/'/g, "''");
1308
+ const PY_Q = PY_DIR ? String(PY_DIR).replace(/'/g, "''") : null;
1309
+ const body = [
1310
+ "$ErrorActionPreference = 'Continue'",
1311
+ // 根治:最外层 trap 兜底——任何未捕获的 terminating error 都恒 exit 0,绝不让 launcher 以非 0 退出,
1312
+ // 否则 Windows Terminal 会留下「[已退出进程,代码为 1]」且不自动关的卡壳窗。
1313
+ "trap { exit 0 }",
1314
+ `$PID | Set-Content -Path '${pidfQ}' -Encoding UTF8`,
1315
+ "$env:TERM = 'xterm-256color'",
1316
+ ...(PY_Q ? ["$env:Path = '" + PY_Q + ";' + $env:Path"] : []),
1317
+ "$env:ORCH_WINDOW = '1'",
1318
+ "if ($env:ORCH_SDIR) { $env:ORCH_SDIR = $env:ORCH_SDIR } elseif ($env:ORCH_SESSION_DIR) { $env:ORCH_SDIR = $env:ORCH_SESSION_DIR } elseif ($env:CODEX_HOME) { $env:ORCH_SDIR = Join-Path $env:CODEX_HOME 'sessions' } else { $env:ORCH_SDIR = Join-Path $env:USERPROFILE '.codex\\sessions' }",
1319
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
1320
+ "$OutputEncoding = [System.Text.Encoding]::UTF8",
1321
+ // Align BOTH console code pages to UTF-8: on a Chinese-locale Windows the default INPUT code page
1322
+ // is 936 (GBK); Codex expects UTF-8 and the Microsoft Pinyin/TSF IME composes against the console
1323
+ // code page. chcp 65001 + InputEncoding UTF-8 keep IME composition UTF-8 (best-effort for CJK).
1324
+ "chcp 65001 | Out-Null",
1325
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
1326
+ "Write-Host '============================================='",
1327
+ "Write-Host ' TASK RUNNING - closes automatically on completion'",
1328
+ "Write-Host '============================================='",
1329
+ "$resultFile = '" + rfQ + "'",
1330
+ // 加固:结果/完成标记写入前先确保父目录存在。避免“launcher 产物目录在运行中被删除/被插件升级重装清掉”
1331
+ // 后,Set-Content 因父目录不存在而抛 DirectoryNotFoundException,导致窗以 exit 1 退出且不自动关(常见根因)。
1332
+ "function Ensure-ResultDir {",
1333
+ " try { $d = Split-Path -Parent $resultFile; if ($d -and -not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null } } catch { }",
1334
+ "}",
1335
+ "function Get-LastReport {",
1336
+ " param([string]$dir,[string]$tok)",
1337
+ " $res = ''",
1338
+ " $files = Get-ChildItem -LiteralPath $dir -Recurse -Filter 'rollout-*.jsonl' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt (Get-Date).AddMinutes(-30) }",
1339
+ " foreach ($f in $files) {",
1340
+ " $raw = Get-Content -LiteralPath $f.FullName -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
1341
+ " if ($raw -and $raw.Contains($tok)) {",
1342
+ " foreach ($line in ($raw -split \"`n\")) {",
1343
+ " try {",
1344
+ " $o = $line | ConvertFrom-Json -ErrorAction SilentlyContinue",
1345
+ " $p = $o.payload",
1346
+ " $msg = ''",
1347
+ " if ($p -and $p.type -eq 'agent_message' -and $p.message) { $msg = $p.message }",
1348
+ " elseif ($o.type -eq 'response_item' -and $p -and $p.type -eq 'message' -and $p.role -eq 'assistant' -and $p.content) { $msg = (($p.content | ForEach-Object { if ($_.text) { $_.text } elseif ($_.content) { $_.content } elseif ($_.value) { $_.value } }) -join '') }",
1349
+ " if ($msg -and $msg.Trim()) { $res = $msg }",
1350
+ " } catch { }",
1351
+ " }",
1352
+ " }",
1353
+ " }",
1354
+ " return $res",
1355
+ "}",
1356
+ "function Write-Result {",
1357
+ " param([int]$code)",
1358
+ " try {",
1359
+ " # 执行结束判定(用户方案:done=做完了,不等同于成功):agent 已退出即视为本窗执行结束,写 result + .self-closed 完成标记。",
1360
+ " # 是否真的达成 prompt 预期,由主控关窗后去核对真实落盘状态;这里不因其未输出长篇汇报就判定未完成。",
1361
+ " $dir = $env:ORCH_SDIR",
1362
+ " $report = Get-LastReport -dir $dir -tok $token",
1363
+ " $line = '__EXIT__=' + $code",
1364
+ " $json = '{\"__EXIT__\":' + $code + ',\"__DONE__\":true,\"__REPORT__\":' + (ConvertTo-Json $report -Compress) + ',\"ts\":\"' + (Get-Date -Format o) + '\"}'",
1365
+ " Ensure-ResultDir",
1366
+ " Set-Content -LiteralPath $resultFile -Value ($json + \"`r`n\" + $line) -Encoding UTF8",
1367
+ " Ensure-ResultDir; try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { /* ignore */ }",
1368
+ " } catch { }",
1369
+ "}",
1370
+ "$p = (Get-Content -Raw -Encoding UTF8 '" + pfQ + "').Trim()",
1371
+ "if ([string]::IsNullOrWhiteSpace($p)) { Write-Result 2; exit 0 }",
1372
+ "$token = '" + token + "'",
1373
+ // Self-close watchdog (background job): when this window's own rollout reaches task_complete, kill
1374
+ // the launcher's FULL codex descendant tree (recursive along ParentProcessId, each PID killed
1375
+ // individually) so `& codex` returns and the launcher runs exit 0 -> the Windows Terminal pane
1376
+ // auto-closes ~1s after completion. Also exits when the result marker exists (codex already exited
1377
+ // on its own), and the recursive kill skips when no descendants remain. Never touches the launcher,
1378
+ // the watchdog job itself, or other windows.
1379
+ "$watch = Start-Job -ScriptBlock {",
1380
+ " param($token, $rootPid, $resultFile)",
1381
+ " if ([string]::IsNullOrWhiteSpace($token)) { return }",
1382
+ " # 独立 runspace:本 job 看不到主作用域的 Ensure-ResultDir,需在此自行复刻(与主作用域实现一致)。",
1383
+ " function Ensure-ResultDir {",
1384
+ " try { $d = Split-Path -Parent $resultFile; if ($d -and -not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null } } catch { }",
1385
+ " }",
1386
+ " function Stop-LauncherDescendants {",
1387
+ " param([int]$root)",
1388
+ ...psKillDescendantsLines().map((ln) => " " + ln),
1389
+ " }",
1390
+ " # 加固:连续重扫并递归杀净,直到 launcher 不再有任何后代(codex 若在快照后重新生成子进程也一并回收),",
1391
+ " # 绝不因为一次快照遗漏子进程就放过 codex,造成“窗口任务已完成但 codex TUI 仍存活、窗未自动关”。",
1392
+ " function Kill-LauncherTree {",
1393
+ " param([int]$root)",
1394
+ " for ($k = 0; $k -lt 3; $k++) {",
1395
+ " $n = Stop-LauncherDescendants $root",
1396
+ " if ($n -eq 0) { break }",
1397
+ " Start-Sleep -Milliseconds 300",
1398
+ " }",
1399
+ " }",
1400
+ " $dir = $env:ORCH_SDIR",
1401
+ " $seen = @{}",
1402
+ " $sizes = @{}",
1403
+ " $match = $null",
1404
+ " $sawChild = $false",
1405
+ " $ownerTurnId = ''",
1406
+ " $watchdogMaxMin = 45",
1407
+ " try { if ($env:ORCH_WATCHDOG_MAX_MIN) { $watchdogMaxMin = [double]$env:ORCH_WATCHDOG_MAX_MIN } } catch { }",
1408
+ " $deadline = (Get-Date).AddMinutes($watchdogMaxMin)",
1409
+ " while ((Get-Date) -lt $deadline) {",
1410
+ " if (Test-Path -LiteralPath $resultFile) {",
1411
+ " $n = Stop-LauncherDescendants $rootPid",
1412
+ " if ($n -gt 0) { $sawChild = $true }",
1413
+ " # 加固:陈旧/旧块 result 文件可能在 codex 尚未生成后代时误触发提前退出;故仅当确曾观测到本窗 codex",
1414
+ " # 后代($sawChild)且现已杀净时才结束,并补写 .self-closed 完成标记,避免“任务完成但窗未自动关”。",
1415
+ " if ($n -eq 0 -and $sawChild) { Ensure-ResultDir; try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }; exit 0 }",
1416
+ " }",
1417
+ " try {",
1418
+ " $files = Get-ChildItem -LiteralPath $dir -Recurse -Filter 'rollout-*.jsonl' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt (Get-Date).AddMinutes(-30) }",
1419
+ " foreach ($f in $files) {",
1420
+ " if ($match) {",
1421
+ " if ($f.FullName -ne $match) { continue }",
1422
+ " try {",
1423
+ " $tail = Get-Content -LiteralPath $f.FullName -Tail 200 -Encoding UTF8 -ErrorAction SilentlyContinue",
1424
+ " # 自动关闭设计 = watchdog 检测到【本块 agent 的完成汇报】(agent_message/assistant 行含本块 token 且含 完成) 才关窗。",
1425
+ " # 计划/中间消息不带“完成”,不会被误关;仅真正的完成汇报触发。稳定~1.5s 后关,确保父进程已抓到汇报。",
1426
+ " # 加固:__ORCH_DONE__ 分支必须同时命中「agent/assistant 消息」,绝不能匹配到被注入的【任务/执行纪律提示词】——",
1427
+ " # 该提示词里原文含 '__ORCH_DONE__ <本窗token>'(教 agent 最后怎么汇报),若不过滤 role/type,watchdog 会",
1428
+ " # 在窗口刚拿到任务 prompt、agent 还没产出任何结果时就把这条『用户消息』误判成完成汇报,随即自关窗、丢任务。",
1429
+ " # 加固:__ORCH_DONE__ 分支必须同时命中「agent/assistant 消息」,绝不能匹配到被注入的【任务/执行纪律提示词】——",
1430
+ " # 该提示词里原文含 '__ORCH_DONE__ <本窗token>'(教 agent 最后怎么汇报),若不过滤 role/type,watchdog 会",
1431
+ " # 在窗口刚拿到任务 prompt、agent 还没产出任何结果时就把这条『用户消息』误判成完成汇报,随即自关窗、丢任务。",
1432
+ " # 层级化:watchdog(爷爷)只认 TUI 窗(儿子)是否完成任务——只要 TUI 窗输出 __ORCH_DONE__ <token> 就认为收尾。",
1433
+ " # watchdog 只认 TUI 窗(儿子)输出 __ORCH_DONE__ <token> 即收尾;不再有任何 SUBAGENTS_DONE 后缀。",
1434
+ " $done = ($tail | Where-Object { ($_ -match 'agent_message|\"role\":\"assistant\"') -and $_.Contains('__ORCH_DONE__') -and $_.Contains($token) } | Select-Object -Last 1)",
1435
+ " if ($done) {",
1436
+ " Ensure-ResultDir; try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }",
1437
+ " # 递归杀净 launcher 的全部后代(agent 树):绝不强杀 launcher 自身;agent 已退出(无后代)则函数直接跳过。",
1438
+ " Kill-LauncherTree $rootPid",
1439
+ " exit 0",
1440
+ " }",
1441
+ " $report = ($tail | Where-Object { ($_ -match 'agent_message|\"role\":\"assistant\"') -and $_.Contains($token) -and $_.Contains('完成') } | Select-Object -Last 1)",
1442
+ " if ($report) {",
1443
+ " # 命中即收窗:rollout 中最后一条含本窗 token+完成 的 assistant 消息即最终汇报(模板要求最终汇报以 token 开头、禁止中途输出未完状态),出现即写强标记并结束,无需等 1.5s 稳定。",
1444
+ " Ensure-ResultDir; try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }",
1445
+ " # 同上:递归杀净 launcher 的全部后代(agent 树),绝不强杀 launcher 自身,agent 已退出则自动跳过。",
1446
+ " Kill-LauncherTree $rootPid",
1447
+ " exit 0",
1448
+ " }",
1449
+ " # 兜底:若本窗 rollout 已达到结构化 task_complete,但既无 __ORCH_DONE__ 也无『完成』汇报(如 selftest/纯只读窗),",
1450
+ " # 也立即关窗——否则会落到下方依赖 $sawChild 的陈旧 result 分支,拖到 orchestrator 的 15s 兜底才收窗(慢/易卡)。",
1451
+ " # 只解析 JSONL 事件,拒绝把普通提示词/消息中的 task_complete 字样当作生命周期信号。",
1452
+ " # token 所在的 prompt 行提供 owner turn_id;若完成事件带 turn_id,必须与 owner 相同;无 turn_id 的 CLI 只有在事件行自带 token 时才兼容放行。",
1453
+ " $tcDone = $false",
1454
+ " foreach ($line in $tail) {",
1455
+ " try {",
1456
+ " $ev = $line | ConvertFrom-Json -ErrorAction Stop",
1457
+ " $payload = $ev.payload",
1458
+ " $turnId = ''",
1459
+ " if ($ev.turn_id) { $turnId = [string]$ev.turn_id } elseif ($payload -and $payload.turn_id) { $turnId = [string]$payload.turn_id }",
1460
+ " if ($line.Contains($token) -and $turnId -and -not $ownerTurnId) { $ownerTurnId = $turnId }",
1461
+ " $eventType = if ($ev.type) { [string]$ev.type } else { '' }",
1462
+ " $payloadType = if ($payload -and $payload.type) { [string]$payload.type } else { '' }",
1463
+ " $isTaskComplete = ($eventType -eq 'task_complete' -or $payloadType -eq 'task_complete')",
1464
+ " if ($isTaskComplete -and (($line.Contains($token)) -or ($turnId -and $ownerTurnId -and $turnId -eq $ownerTurnId))) { $tcDone = $true; break }",
1465
+ " } catch { }",
1466
+ " }",
1467
+ " if ($tcDone) {",
1468
+ " Ensure-ResultDir; try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }",
1469
+ " Kill-LauncherTree $rootPid",
1470
+ " exit 0",
1471
+ " }",
1472
+ " } catch { }",
1473
+ " continue",
1474
+ " }",
1475
+ " $cur = 0",
1476
+ " try { $cur = $f.Length } catch { $cur = 0 }",
1477
+ " if ($seen.ContainsKey($f.FullName) -and $sizes[$f.FullName] -eq $cur) { continue }",
1478
+ " $seen[$f.FullName] = $true",
1479
+ " $sizes[$f.FullName] = $cur",
1480
+ " try {",
1481
+ " $raw = Get-Content -LiteralPath $f.FullName -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
1482
+ " if ($raw -and $raw.Contains($token)) {",
1483
+ " $match = $f.FullName",
1484
+ " # 首次命中时从完整 JSONL 中提取 token 所属 turn,避免长 rollout 把 prompt 滚出尾部后无法绑定完成事件。",
1485
+ " if (-not $ownerTurnId) {",
1486
+ " foreach ($line in ($raw -split \"`n\")) {",
1487
+ " if (-not $line.Contains($token)) { continue }",
1488
+ " try {",
1489
+ " $ev0 = $line | ConvertFrom-Json -ErrorAction Stop",
1490
+ " $p0 = $ev0.payload",
1491
+ " if ($ev0.turn_id) { $ownerTurnId = [string]$ev0.turn_id } elseif ($p0 -and $p0.turn_id) { $ownerTurnId = [string]$p0.turn_id }",
1492
+ " if ($ownerTurnId) { break }",
1493
+ " } catch { }",
1494
+ " }",
1495
+ " }",
1496
+ " break",
1497
+ " }",
1498
+ " } catch { }",
1499
+ " }",
1500
+ " } catch { }",
1501
+ " Start-Sleep -Milliseconds 500",
1502
+ " }",
1503
+ " # 兜底(明显问题:卡死/无进展/异常未正常汇报):watchdog 到达 deadline 仍未见 __ORCH_DONE__/task_complete,",
1504
+ " # 视为异常窗口——写完成标记 + 杀净 agent 后代,让 `& codex` 返回、launcher 恒 exit 0 干净关窗;",
1505
+ " # 是否真达成 prompt 预期由主控关窗后按盘面复核(此处只保证不挂死、不残留 code-1/常驻窗)。",
1506
+ " Ensure-ResultDir",
1507
+ " try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }",
1508
+ " Kill-LauncherTree $rootPid",
1509
+ " exit 0",
1510
+ "} -ArgumentList $token, $PID, $resultFile",
1511
+ "$code = 1",
1512
+ "try {",
1513
+ " $agentArgs = " + buildPsArrayLit(AGENT_ARGS_LIST) + " + $p",
1514
+ " # Only the controller may recover checked remaining tasks; never replay the whole prompt here.",
1515
+ " $attempt = 0",
1516
+ " $rep = ''",
1517
+ " $wdDone = $false",
1518
+ ...nativeArgumentLines(),
1519
+ " while ($attempt -lt 1) {",
1520
+ " $attempt++",
1521
+ " & '" + AGENT_BIN_Q + "' @agentArgs",
1522
+ " $code = $LASTEXITCODE",
1523
+ " $rdir = $env:ORCH_SDIR",
1524
+ " $rep = Get-LastReport -dir $rdir -tok $token",
1525
+ " if ($rep) { break }",
1526
+ " # 加固:watchdog 刚杀完 codex,rollout 汇报 / .self-closed 标记可能仍在落盘/跨进程可见性传播中,",
1527
+ " # 先短等再复检,避免把“已完成、正在关窗”的窗口误判成失败,重跑出一个失去 watchdog 背书的常驻 codex。",
1528
+ " Start-Sleep -Milliseconds 400",
1529
+ " $rep = Get-LastReport -dir $rdir -tok $token",
1530
+ " if ($rep) { break }",
1531
+ " # 加固:watchdog 已自关(已写入 .self-closed 完成标记)就意味着完成汇报已被确认,此时绝不再重跑,",
1532
+ " # 避免 agent 重新生成一个失去 watchdog 背书的常驻 TUI(正是“窗口任务完成但 agent 仍存活、窗未自动关”的根因)。",
1533
+ " if (Test-Path -LiteralPath ($resultFile + '.self-closed')) { $wdDone = $true; break }",
1534
+ " if ($code -eq 0) { break }",
1535
+ " Write-Host (' [launcher] codex session exited (code=' + $code + '); controller will reconcile task checkpoints')",
1536
+ " }",
1537
+ "} catch {",
1538
+ " $code = 1",
1539
+ "}",
1540
+ // 任务完成判定:只要本窗最终汇报已捕获($rep 非空),watchdog 会为快速关窗而强杀 agent 子进程使
1541
+ // $LASTEXITCODE=1,但那不等于失败。此时统一按完成处理并以 exit 0 干净关闭(Windows Terminal
1542
+ // closeOnExit:graceful 只认 exit 0;非零会留下“[进程已退出,代码为 1]”的卡壳窗),结果文件也记录 0。
1543
+ // 拿到最终汇报($rep)或 watchdog 已确认完成($wdDone)都归零,确保窗必关、绝不留常驻 codex。
1544
+ "if ($rep -or $wdDone) { $code = 0 }",
1545
+
1546
+ "if ($rep -or $wdDone) { Ensure-ResultDir; try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { /* ignore */ } }",
1547
+ "if($rep -or $wdDone){$null=Wait-Job -Job $watch -Timeout 8}",
1548
+ "Write-Result $code",
1549
+ // Always exit 0: Windows Terminal auto-closes the pane on exit code 0 (closeOnExit:graceful).
1550
+ // A non-zero exit keeps the pane open ("process exited, code X") which is the stuck-window bug.
1551
+ "exit 0",
1552
+ ].join("\r\n");
1553
+ writeFileSync(lp, BOM + body, "utf8");
1554
+ return { lp, pidf, pf, rf };
1555
+ }
1556
+ // ---- session-rollout discovery + task_complete detection (for interactive auto-close) ----
1557
+ function sessionDir() {
1558
+ // 默认 codex 会话目录;ORCH_SESSION_DIR 可指向其它 agent 的会话目录(agent 通用化)。
1559
+ // 通用化:优先 CODEX_HOME(可被环境变量覆盖),而非硬编码 USERPROFILE\.codex。
1560
+ return process.env.ORCH_SESSION_DIR || path.join(CODEX_HOME, "sessions");
1561
+ }
1562
+ function listRollouts(dir) {
1563
+ const res = [];
1564
+ try {
1565
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1566
+ const p = path.join(dir, e.name);
1567
+ if (e.isDirectory()) res.push(...listRollouts(p));
1568
+ else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) res.push(p);
1569
+ }
1570
+ } catch { /* ignore */ }
1571
+ return res;
1572
+ }
1573
+ // Find the session rollout belonging to a block by matching a distinctive slice of its prompt text.
1574
+ // The match token is globally unique per block (it contains a batch id + random suffix), so we do NOT
1575
+ // need a "before" snapshot filter — matching the unique token is enough and avoids the old fragility.
1576
+ // `exitMarkerFile` (the launcher's `win-<s>.result.json`) aborts the wait as soon as codex has exited
1577
+ // on its own (crash / non-zero exit): the caller then reclaims the session immediately instead of
1578
+ // polling for a rollout that may never appear ("codex 进程先退出" -> no launcher hang).
1579
+ function rolloutKeys(matchKey) {
1580
+ // JSON.stringify 不转义 CJK(输出 raw),但 codex rollout 会把 prompt 的 CJK 存成 \uXXXX 转义,
1581
+ // 导致仅凭 raw 匹配会漏。ASCII token 在 raw/转义两种形式中都原样存在,最可靠。
1582
+ const escaped = JSON.stringify(matchKey).slice(1, -1);
1583
+ const ascii = String(matchKey).replace(/[^\x00-\x7F]/g, "");
1584
+ return [escaped, ascii].filter(Boolean);
1585
+ }
1586
+ function rolloutHit(text, keys) { return keys.some((k) => text.includes(k)); }
1587
+ // Parse a rollout tail and accept only a structured task_complete event belonging to this block.
1588
+ // The token normally appears in the user prompt while the terminal event carries the turn_id, so
1589
+ // retain the owner turn across polling iterations and reject text mentions or foreign turns.
1590
+ function scanTaskComplete(text, matchToken = "", ownerTurnId = "") {
1591
+ const keys = matchToken ? rolloutKeys(matchToken) : [];
1592
+ let owner = String(ownerTurnId || "");
1593
+ for (const line of String(text || "").split(/\r?\n/)) {
1594
+ if (!line.trim()) continue;
1595
+ let ev;
1596
+ try { ev = JSON.parse(line); } catch { continue; }
1597
+ const payload = ev && ev.payload && typeof ev.payload === "object" && !Array.isArray(ev.payload) ? ev.payload : {};
1598
+ const turnId = ev && ev.turn_id != null ? String(ev.turn_id) : (payload.turn_id != null ? String(payload.turn_id) : "");
1599
+ const lineOwn = keys.length ? rolloutHit(line, keys) : true;
1600
+ if (lineOwn && turnId && !owner) owner = turnId;
1601
+ const eventType = ev && typeof ev.type === "string" ? ev.type : "";
1602
+ const payloadType = typeof payload.type === "string" ? payload.type : "";
1603
+ const typedComplete = eventType === "task_complete" || payloadType === "task_complete";
1604
+ if (typedComplete && (!keys.length || lineOwn || (turnId && owner && turnId === owner))) return { done: true, ownerTurnId: owner };
1605
+ }
1606
+ return { done: false, ownerTurnId: owner };
1607
+ }
1608
+ async function waitForSessionFile(matchKey, timeoutMs, exitMarkerFile) {
1609
+ const start = Date.now();
1610
+ const seen = new Set();
1611
+ let lastReset = Date.now();
1612
+ // The rollout is JSONL: backslashes (Windows paths) are doubled (`\` -> `\\`) and quotes escaped,
1613
+ // so we must match against the JSON-escaped form, not the raw text. The task prompt sits after the
1614
+ // (large) AGENTS.md/context block, so we read the whole file (once per new candidate), not just the head.
1615
+ const keys = rolloutKeys(matchKey);
1616
+ while (Date.now() - start < timeoutMs) {
1617
+ // 完成信号优先认窗内自关:result 落盘或窗内 watchdog 写入 .self-closed(即 agent 已报 __ORCH_DONE__)都视为本窗完成,
1618
+ // 立即转入回收路径,避免父进程靠 session 文本匹配去等一个可能永远匹配不到的会话(慢速 agent 时长期 HOLDING)。
1619
+ if (exitMarkerFile && (existsSync(exitMarkerFile) || existsSync(exitMarkerFile + ".self-closed"))) return null; // codex already exited -> reclaim path
1620
+ for (const f of listRollouts(sessionDir())) {
1621
+ if (seen.has(f)) continue;
1622
+ seen.add(f);
1623
+ try {
1624
+ if (rolloutHit(readFileSync(f, "utf8"), keys)) return f;
1625
+ } catch { /* ignore */ }
1626
+ }
1627
+ if (Date.now() - lastReset > 15000) { seen.clear(); lastReset = Date.now(); } // 周期重扫:防 rollout 先建后写 token 被永久错过
1628
+ await new Promise((r) => setTimeout(r, 1000));
1629
+ }
1630
+ return null;
1631
+ }
1632
+ // Poll a rollout for the `task_complete` event (the reliable "turn done" marker), then extract the
1633
+ // agent's final message. Returns { done, text, exitCode }. Also treats the launcher's exit marker as a
1634
+ // completion signal (codex exited, whatever the code): a non-zero exit WITH task_complete is a done
1635
+ // turn, and a launcher exit WITHOUT task_complete is still a reclaimable (already-closing) window.
1636
+ async function waitTaskComplete(file, timeoutMs, exitMarkerFile, matchToken, pidf) {
1637
+ const start = Date.now();
1638
+ let ownerTurnId = "";
1639
+ while (Date.now() - start < timeoutMs) {
1640
+ // 根治 run 卡死:窗(launcher 进程)已死 = 该块执行结束。用户方案=完成与否由主控关窗后按任务 prompt
1641
+ // 核对真实落盘(零报告原则),父进程绝不因"窗未写 result 汇报 / rollout 无 task_complete"而无限等待。
1642
+ if (pidf) {
1643
+ if (!existsSync(pidf)) {
1644
+ // pid 文件已被清理 = 窗生命周期已结束(正常关窗/被收尾清理),视为该块执行结束。
1645
+ return { done: true, text: "(window closed; no report captured)", exitCode: readExitCode(exitMarkerFile) };
1646
+ }
1647
+ const lp = readPidFile(pidf);
1648
+ if (lp > 0 && !pidAliveCheck(lp)) {
1649
+ return { done: true, text: "(window closed; no report captured)", exitCode: readExitCode(exitMarkerFile) };
1650
+ }
1651
+ }
1652
+ if (exitMarkerFile && existsSync(exitMarkerFile)) {
1653
+ const rep = readReportFromResult(exitMarkerFile, matchToken);
1654
+ if (rep) return { done: true, text: rep, exitCode: readExitCode(exitMarkerFile) };
1655
+ const code = readExitCode(exitMarkerFile);
1656
+ const base = matchToken ? { text: extractOwnResult(file, matchToken) } : extractResult(file);
1657
+ if (base.text) return { done: true, text: base.text, exitCode: code };
1658
+ return { done: true, text: "(launcher exited code " + (code === null ? "?" : code) + " before task_complete captured)", exitCode: code };
1659
+ }
1660
+ if (exitMarkerFile && existsSync(exitMarkerFile + ".self-closed")) {
1661
+ // watchdog 已写自关标记 = 窗收尾并关闭;即时视为执行结束(无汇报文本,主控按落盘核对)。
1662
+ return { done: true, text: "(window self-closed; no report captured)", exitCode: readExitCode(exitMarkerFile) };
1663
+ }
1664
+ let tail = "";
1665
+ try {
1666
+ const st = statSync(file);
1667
+ const fd = openSync(file, "r");
1668
+ const len = Math.min(65536, st.size);
1669
+ const buf = Buffer.alloc(len);
1670
+ readSync(fd, buf, 0, len, Math.max(0, st.size - len));
1671
+ closeSync(fd);
1672
+ tail = buf.toString("utf8");
1673
+ } catch { /* ignore */ }
1674
+ const completion = scanTaskComplete(tail, matchToken, ownerTurnId);
1675
+ ownerTurnId = completion.ownerTurnId;
1676
+ if (completion.done) {
1677
+ // exit code non-zero but task_complete seen -> still a completed turn
1678
+ return { done: true, text: (matchToken ? extractOwnResult(file, matchToken) : extractResult(file).text), exitCode: readExitCode(exitMarkerFile) };
1679
+ }
1680
+ await new Promise((r) => setTimeout(r, 1000));
1681
+ }
1682
+ return { done: false, text: "" };
1683
+ }
1684
+ // 只读文件尾部窗口(UTF-8, 最多 maxBytes 字节), 用于从超大 rollout 提取末尾的完成汇报。
1685
+ // 完成标记/最终消息总在文件末尾, 全量 readFileSync 读 160MB+ rollout 是纯浪费。
1686
+ // 返回 null 表示文件不可读(与"读到空串"区分)。这是纯函数、无语义改变、O(1) 内存。
1687
+ export function readTail(file, maxBytes = 262144) {
1688
+ let s = "";
1689
+ try {
1690
+ const st = statSync(file);
1691
+ const len = Math.min(maxBytes, st.size);
1692
+ const fd = openSync(file, "r");
1693
+ try {
1694
+ const buf = Buffer.alloc(len);
1695
+ readSync(fd, buf, 0, len, Math.max(0, st.size - len));
1696
+ s = buf.toString("utf8");
1697
+ } finally { closeSync(fd); }
1698
+ } catch { return null; }
1699
+ return s;
1700
+ }
1701
+ function extractResult(file) {
1702
+ // 优化(降时间复杂度/内存): 只读文件尾部窗口(最后 maxTailBytes), 而非 readFileSync 整个 rollout。
1703
+ // 完成汇报/最终消息必然在文件末尾, 全量读对超大 rollout(可达 160MB+)是纯浪费。
1704
+ let full = readTail(file, 262144);
1705
+ if (full === null) return { done: true, text: "" };
1706
+ let last = "";
1707
+ for (const line of full.split("\n")) {
1708
+ const l = line.trim();
1709
+ if (!l) continue;
1710
+ try {
1711
+ const o = JSON.parse(l);
1712
+ const p = o.payload || {};
1713
+ if (p.type === "agent_message" && p.message) last = p.message;
1714
+ else if (o.type === "response_item" && p.type === "message" && p.role === "assistant" && Array.isArray(p.content)) {
1715
+ const t = p.content.map((c) => c.text || c.content || c.value || "").join("");
1716
+ if (t) last = t;
1717
+ }
1718
+ } catch { /* ignore */ }
1719
+ }
1720
+ return { done: true, text: last };
1721
+ }
1722
+ // 真实 token 观测:从 codex rollout(JSONL) 里累积 usage,为 plugin-eval 的 "measure real token usage" 提供确凿数字。
1723
+ // 纯函数、只读尾部窗口(内存 O(1))、容错:字段名兼容 input_tokens/prompt_tokens、output_tokens/completion_tokens、
1724
+ // cached_tokens/cached_input_tokens、total_tokens;从每条含 usage 的 JSON 里取该对象。所有 token 值为缺省 0。
1725
+ function extractUsage(file) {
1726
+ const u = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, total_tokens: 0 };
1727
+ let full;
1728
+ try { full = readTail(file, 262144); } catch { return u; }
1729
+ if (full === null) return u;
1730
+ const pickNum = (o, ...keys) => { for (const k of keys) { if (o && typeof o[k] === "number" && o[k] > 0) return o[k]; } return 0; };
1731
+ for (const line of full.split("\n")) {
1732
+ const l = line.trim();
1733
+ if (!l) continue;
1734
+ try {
1735
+ const o = JSON.parse(l);
1736
+ const p = o && o.payload ? o.payload : o;
1737
+ // usage 可能直接在对象上,或在 payload.response/usage 里
1738
+ const ug = (o && o.usage) || (p && p.usage) || (p && p.response && p.response.usage) || null;
1739
+ if (ug && typeof ug === "object") {
1740
+ u.input_tokens += pickNum(ug, "input_tokens", "prompt_tokens", "input");
1741
+ u.output_tokens += pickNum(ug, "output_tokens", "completion_tokens", "output");
1742
+ u.cached_tokens += pickNum(ug, "cached_tokens", "cached_input_tokens", "cached");
1743
+ u.total_tokens += pickNum(ug, "total_tokens", "total");
1744
+ }
1745
+ } catch { /* ignore */ }
1746
+ }
1747
+ return u;
1748
+ }
1749
+ // 确定性结果文件:读 launcher 写入的 __REPORT__(本块自己的完成汇报),含本块 token+完成 才返回;否则 ""。
1750
+ function readReportFromResult(resultFile, token) {
1751
+ if (!resultFile || !existsSync(resultFile)) return "";
1752
+ try {
1753
+ const raw = String(readFileSync(resultFile, "utf8")).replace(/^\uFEFF/, "");
1754
+ const m = raw.match(/\{[\s\S]*\}/);
1755
+ const j = JSON.parse(m ? m[0] : raw);
1756
+ const rep = String(j.__REPORT__ || "");
1757
+ if (rep && String(rep).trim()) return rep;
1758
+ } catch { /* ignore */ }
1759
+ return "";
1760
+ }
1761
+ // 在(可能混合了多窗消息的)rollout 里,取“含本块唯一 token 的最后一条消息”(真正的本块完成汇报),
1762
+ // 避免混合会话下 extractResult 取到别的窗的“全局最后一条”而跨窗串号。
1763
+ function extractOwnResult(file, token) {
1764
+ if (!token) return "";
1765
+ // 优化: 只读尾部窗口, 避免全量读超大 rollout。
1766
+ let full = readTail(file, 262144);
1767
+ if (full === null) return "";
1768
+ let last = "";
1769
+ for (const line of full.split("\n")) {
1770
+ const l = line.trim();
1771
+ if (!l) continue;
1772
+ try {
1773
+ const o = JSON.parse(l);
1774
+ const p = o.payload || {};
1775
+ let msg = "";
1776
+ if (p.type === "agent_message" && p.message) msg = p.message;
1777
+ else if (o.type === "response_item" && p.type === "message" && p.role === "assistant" && Array.isArray(p.content)) msg = p.content.map((c) => c.text || c.content || c.value || "").join("");
1778
+ if (msg && msg.includes(token)) last = msg;
1779
+ } catch { /* ignore */ }
1780
+ }
1781
+ return last;
1782
+ }
1783
+ // Re-scan rollouts for the block's report and return the latest agent message, or "" if none found yet.
1784
+ // Used so a window is only auto-closed AFTER its report has been captured ("close anchor = report captured"),
1785
+ // instead of blind-grace timing. Bounded by `timeoutMs`.
1786
+ async function waitForReportText(matchKey, exitMarkerFile, timeoutMs) {
1787
+ const start = Date.now();
1788
+ const seen = new Set();
1789
+ const keys = rolloutKeys(matchKey);
1790
+ while (Date.now() - start < timeoutMs) {
1791
+ for (const f of listRollouts(sessionDir())) {
1792
+ if (seen.has(f)) continue;
1793
+ seen.add(f);
1794
+ try {
1795
+ if (rolloutHit(readFileSync(f, "utf8"), keys)) {
1796
+ const t = extractOwnResult(f, matchKey);
1797
+ if (t) return t;
1798
+ }
1799
+ } catch { /* ignore */ }
1800
+ }
1801
+ await new Promise((r) => setTimeout(r, 500));
1802
+ }
1803
+ return "";
1804
+ }
1805
+ // Close a window GRACEFULLY so Windows Terminal auto-closes the pane. Windows Terminal hosts the
1806
+ // launcher powershell (the profile process) with closeOnExit:"graceful": exit code 0 -> the pane
1807
+ // auto-closes; a NON-zero exit -> it keeps the pane open and shows "[已退出进程,代码为 X]". The launcher
1808
+ // ends with `... & codex ...; exit 0`, so if we recursively kill every launcher descendant (the full
1809
+ // codex tree, each PID individually), `& codex` returns and the launcher runs `exit 0` -> Windows
1810
+ // Terminal auto-closes the pane cleanly.
1811
+ // Force-killing the launcher itself (the old `taskkill /PID <launcher> /T /F`) is exactly what made it
1812
+ // exit non-zero and left the stuck "[process exited, code 1]" pane. So we target the codex child
1813
+ // subtree, never the launcher, then poll for the launcher to exit on its own (exit 0). If it is
1814
+ // genuinely stuck we fall back to a force-kill (last resort / never an orphan) and report it.
1815
+ function readPidFile(pidf) {
1816
+ try {
1817
+ const p = Number(String(readFileSync(pidf, "utf8")).trim());
1818
+ // 与 force.mjs 的 pidOf 一致:只认 1..4194304 的合理 PID,超出/非正数一律视为无效(0),
1819
+ // 防止异常大/负值被当成活进程(process.kill 对大 pid 也可能因平台行为产生困惑)。
1820
+ return Number.isFinite(p) && p >= 1 && p <= 4194304 ? p : 0;
1821
+ } catch { return 0; }
1822
+ }
1823
+ function pidAliveCheck(pid) {
1824
+ const p = Number(pid);
1825
+ if (!Number.isFinite(p) || p <= 0) return false;
1826
+ try { process.kill(p, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
1827
+ }
1828
+ async function closeWindowByPid(pid, expected) {
1829
+ const p = Number(pid);
1830
+ if (!Number.isFinite(p) || p <= 0) return false;
1831
+ if (!pidAliveCheck(p)) return true;
1832
+ if(!expected)return false;
1833
+ if(!sameProcess(expected,processSnapshot().get(p)))return true;
1834
+ // Recursively enumerate ALL launcher descendants (the full codex tree) along the ParentProcessId
1835
+ // chain and kill each PID individually, so `& codex` always returns and the launcher reaches its own
1836
+ // `exit 0`. Never touches the launcher itself; when codex already exited (no descendants) nothing is
1837
+ // killed. spawnSync passes args as an array -> no shell arg-splitting of the multi-line script.
1838
+ const killScript =
1839
+ "$ErrorActionPreference='SilentlyContinue';" + "\r\n" +
1840
+ "$root=" + p + ";" + "\r\n" +
1841
+ psKillDescendantsLines().join("\r\n").replace('$all = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue',
1842
+ '$all = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue; $actual=$all | Where-Object ProcessId -eq '+p+'; if(-not $actual -or $actual.CreationDate.ToUniversalTime().ToString("o") -ne '+buildPsArrayLit([expected.Started])+'[0] -or $actual.CommandLine -ne '+buildPsArrayLit([expected.CommandLine])+'[0]) { exit 0 }');
1843
+ let killedCount = 0;
1844
+ try {
1845
+ const r = spawnSync(PS_EXE, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", killScript], { encoding: "utf8", windowsHide: true });
1846
+ killedCount = Number(String(r.stdout || "").trim().split(/\r?\n/).pop()) || 0;
1847
+ } catch { /* ignore */ }
1848
+ // Poll for the launcher to exit on its own; the launcher runs `exit 0` right after `& codex` returns.
1849
+ const deadline = Date.now() + 60000;
1850
+ while (Date.now() < deadline) {
1851
+ await new Promise((r) => setTimeout(r, 500));
1852
+ if (!sameProcess(expected,processSnapshot().get(p))) return true;
1853
+ }
1854
+ if (killedCount === 0) {
1855
+ // agent 已退出 / 无后代可杀:launcher 应自行 Write-Result + exit 0。绝不在此时强杀 launcher,
1856
+ // 否则 Windows Terminal 会留下 "[已退出进程,代码为 1]" 的误报窗。
1857
+ console.log(" !! launcher " + p + " still alive but has no codex descendants (codex already exited); skipping force-kill and leaving it to exit 0 on its own.");
1858
+ return false;
1859
+ }
1860
+ // Last resort (genuine exception only): the launcher did NOT honor `exit 0` within the grace window.
1861
+ // 根治:绝不 taskkill launcher 本体——它只会以 exit 1 亡,Windows Terminal 留下「[已退出进程,代码为 1]」
1862
+ // 且不自动关的卡壳窗。既然 agent 后代已杀净、launcher 模板带 trap{exit 0} 恒以 0 收尾,就把它留给它自己
1863
+ // exit 0(窗格随之干净关闭),仅如实记录,绝不制造 code-1 残窗。
1864
+ console.log(" !! launcher " + p + " did not exit after killing its codex child; leaving it to self-exit 0 (no force-kill, avoids code-1 pane).");
1865
+ return false;
1866
+ }
1867
+ // 多路定位 launcher:pidFile -> registry pid -> 按 win-launch 脚本路径扫描进程;pidFile 被外部清理时依然能关窗。
1868
+ async function closeWindowRobust(w) {
1869
+ const pidf = w && w.pidFile;
1870
+ let pid = (pidf && existsSync(pidf)) ? readPidFile(pidf) : 0;
1871
+ if (!pid && w && Number(w.pid)) pid = Number(w.pid);
1872
+ if (!pid && pidf) {
1873
+ const lp = path.join(path.dirname(pidf), path.basename(pidf).replace(/^window-/, "win-launch-").replace(/\.pid$/, ".ps1"));
1874
+ try {
1875
+ const esc = String(lp).replace(/'/g, "''");
1876
+ const r = spawnSync(PS_EXE, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
1877
+ "$ErrorActionPreference='SilentlyContinue'; $q = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*" + esc + "*' } | Select-Object -First 1; if ($q) { $q.ProcessId }"], { encoding: "utf8", windowsHide: true });
1878
+ pid = Number(String(r.stdout || "").trim());
1879
+ } catch { /* ignore */ }
1880
+ }
1881
+ if (!pid) return false;
1882
+ if (pidf) { try { writeFileSync(pidf + ".self-closed", "1", "ascii"); } catch { /* ignore */ } }
1883
+ await closeWindowByPid(pid, launcherIdentity(pid,pidf));
1884
+ // Close only this launcher subtree; the terminal host may own other live windows.
1885
+ return true;
1886
+ }
1887
+ async function closeWindow(pidf) {
1888
+ if (!pidf || !existsSync(pidf)) return;
1889
+ try { writeFileSync(pidf + ".self-closed", "1", "ascii"); } catch { /* ignore */ }
1890
+ await closeWindowByPid(readPidFile(pidf),launcherIdentity(readPidFile(pidf),pidf));
1891
+ // Close only this launcher subtree; the terminal host may own other live windows.
1892
+ }
1893
+ // ---------------------------------------------------------------------------
1894
+ // Running-window registry + conflict locking (stability)
1895
+ // ---------------------------------------------------------------------------
1896
+ const WIN_REG = path.join(STATE_DIR, "orchestrator-windows.json");
1897
+ const LOCK_REG = path.join(STATE_DIR, "orchestrator-locks.json");
1898
+ const REG_LOCK = path.join(STATE_DIR, "orchestrator-registry.lock");
1899
+ function sleepSync(ms) { try { const sab = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sab, 0, 0, ms); } catch { /* ignore */ } }
1900
+ // 跨进程独占锁:openSync 'wx' 原子创建;冲突则有限重试;finally 释放。用于串行化 registry 写,防并发丢失/半写。
1901
+ // 锁带 owner+时间戳:持锁进程被杀(guardian 恢复场景属正常运维)会留下 stale 锁——超龄即安全回收,
1902
+ // 否则此后每次 registry 操作都先 2s 忙等再在「无互斥」下读写(冲突门完整性被静默削弱)。
1903
+ const REG_LOCK_STALE_MS = 60000;
1904
+ function withRegistryLock(fn) {
1905
+ const acquire = () => {
1906
+ for (let i = 0; i < 100; i++) {
1907
+ let fd;
1908
+ try { fd = openSync(REG_LOCK, 'wx'); writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); closeSync(fd); return true; } catch { sleepSync(20); }
1909
+ }
1910
+ return false;
1911
+ };
1912
+ let got = acquire();
1913
+ if (!got) {
1914
+ let stale = true;
1915
+ try { stale = Date.now() - statSync(REG_LOCK).mtimeMs > REG_LOCK_STALE_MS; } catch { /* vanished between attempts: retry directly */ }
1916
+ if (stale) { try { rmSync(REG_LOCK, { force: true }); } catch { /* ignore */ } got = acquire(); }
1917
+ }
1918
+ if (!got) console.warn('[orchestrator] registry lock contended; proceeding without mutual exclusion');
1919
+ try { return fn(); }
1920
+ finally { if (got) { try { rmSync(REG_LOCK, { force: true }); } catch { /* ignore */ } } }
1921
+ }
1922
+ function loadJson(file, fallback) {
1923
+ // 写侧在 Windows 上做 "remove+rename"(原子替换),会瞬时让文件不存在;读侧短暂重试即可桥接该间隙。
1924
+ let lastError = null;
1925
+ for (let i = 0; i < 3; i++) {
1926
+ try { return JSON.parse(readFileSync(file, "utf8")); }
1927
+ catch (error) { lastError = error; if (i < 2) sleepSync(12); }
1928
+ }
1929
+ // A missing registry is a normal first-run condition. Malformed state is
1930
+ // different: treating it as an empty registry could bypass the capacity or
1931
+ // conflict gate and start too many windows. Fail closed after the bounded
1932
+ // atomic-replace grace period instead of silently discarding state.
1933
+ if (lastError?.code === "ENOENT") return fallback;
1934
+ throw new Error("Invalid orchestration state: " + file);
1935
+ }
1936
+ function saveJson(file, data) {
1937
+ mkdirSync(STATE_DIR, { recursive: true });
1938
+ const tmp = file + ".tmp-" + process.pid;
1939
+ try {
1940
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
1941
+ try { renameSync(tmp, file); } catch (e) {
1942
+ // Windows renameSync 不覆盖已存在目标;先移除再重命名,避免留下半写 registry。
1943
+ if (e && (e.code === "EEXIST" || e.code === "EPERM" || e.code === "EACCES")) {
1944
+ try { rmSync(file, { force: true }); } catch {}
1945
+ try { renameSync(tmp, file); } catch (e2) { rmSync(tmp, { force: true }); throw e2; }
1946
+ } else { rmSync(tmp, { force: true }); throw e; }
1947
+ }
1948
+ } catch (e) { try { rmSync(tmp, { force: true }); } catch {} throw e; }
1949
+ }
1950
+ function loadWindows() { return loadJson(WIN_REG, []); }
1951
+ function saveWindows(w) { withRegistryLock(() => saveJson(WIN_REG, w)); }
1952
+ function loadLocks() { return loadJson(LOCK_REG, []); }
1953
+ function saveLocks(l) { withRegistryLock(() => saveJson(LOCK_REG, l)); }
1954
+ // 全事务:load+mutate+save 在同一个锁内完成,杜绝跨进程 lost-update。
1955
+ function updateWindows(mutator) { withRegistryLock(() => { const w = loadJson(WIN_REG, []); saveJson(WIN_REG, mutator(w)); }); }
1956
+ function updateLocks(mutator) { withRegistryLock(() => { const l = loadJson(LOCK_REG, []); saveJson(LOCK_REG, mutator(l)); }); }
1957
+ // ---- 跨进程原子占位(防「检查-弹窗」间隙双进程同键竞态)----
1958
+ // 弹窗前先在独占锁内写入 pending=true 的记录(pid=0、预留 pidFile),其它编排进程把新鲜占位视作已占用;
1959
+ // 弹出成功后再 commit 真实 pid,失败则 cancel。崩溃遗留的占位超过 ORCH_RESERVE_TTL_MS(默认 120s)自动作废。
1960
+ const RESERVE_TTL_MS = Math.max(30000, Number(process.env.ORCH_RESERVE_TTL_MS || 120000));
1961
+ function freshPending(w) {
1962
+ return !!(w && w.pending === true && Number(w.reservedAt || 0) > Date.now() - RESERVE_TTL_MS);
1963
+ }
1964
+ // ---- 测试注入点(仅测试用,生产缺省走真实实现;避免单测触发真实 tasklist/文件锁)----
1965
+ // hooks: { db, alive, lock, now }。db=注册表 json 路径;alive=windowAlive 替代;lock=withRegistryLock 替代;now=Date.now 替代。
1966
+ let __orchHooks = null;
1967
+ function testDb() { return (__orchHooks && __orchHooks.db) ? String(__orchHooks.db) : WIN_REG; }
1968
+ function testAlive() { return (__orchHooks && __orchHooks.alive) ? __orchHooks.alive : (w) => windowAlive(w); }
1969
+ function testNow() { return (__orchHooks && __orchHooks.now) ? __orchHooks.now : Date.now; }
1970
+ function testFresh(w) {
1971
+ return !!(w && w.pending === true && Number(w.reservedAt || 0) > testNow()() - RESERVE_TTL_MS);
1972
+ }
1973
+ function testLock(fn) { return (__orchHooks && __orchHooks.lock) ? __orchHooks.lock(fn) : withRegistryLock(fn); }
1974
+ function testUpdateWindows(mutator) {
1975
+ return testLock(() => { const w = loadJson(testDb(), []); saveJson(testDb(), mutator(w)); });
1976
+ }
1977
+ function __setOrchHooks(h) { __orchHooks = h || null; }
1978
+ function tryReserveWindow(rec) {
1979
+ let conflictTitle = null;
1980
+ testLock(() => {
1981
+ const ws = loadJson(testDb(), []);
1982
+ const cleaned = ws.filter((w) => !(w && w.pending === true && !testFresh(w))); // 顺手回收死占位
1983
+ for (const w of cleaned) {
1984
+ if (testAlive()(w) || testFresh(w)) {
1985
+ const wk = Array.isArray(w.keys) ? w.keys.map(String) : (w.title ? ["__" + w.title] : []);
1986
+ if (keysOverlap(Array.isArray(rec.keys) ? rec.keys.map(String) : [], wk)) {
1987
+ conflictTitle = w.title || "?";
1988
+ break;
1989
+ }
1990
+ }
1991
+ }
1992
+ if (!conflictTitle) {
1993
+ cleaned.push(rec);
1994
+ saveJson(testDb(), cleaned);
1995
+ } else if (cleaned.length !== ws.length) {
1996
+ saveJson(testDb(), cleaned); // 只清死占位也落盘,避免堆积
1997
+ }
1998
+ });
1999
+ return conflictTitle;
2000
+ }
2001
+ function commitReservation(id, pid) {
2002
+ testUpdateWindows((ws) => ws.map((w) => w.id === id ? { ...w, pid: Number(pid) || w.pid, identity: __orchHooks ? undefined : launcherIdentity(pid,w.pidFile), pending: false, reservedAt: testNow()() } : w));
2003
+ }
2004
+ function cancelReservation(id) {
2005
+ testUpdateWindows((ws) => ws.filter((w) => w.id !== id));
2006
+ }
2007
+ // The launcher writes its codex-exit marker to `win-<s>.result.json` (same dir as the pidfile).
2008
+ function resultFileOf(pidf) {
2009
+ return pidf ? path.join(path.dirname(pidf), path.basename(pidf).replace(/^window-/, "win-").replace(/\.pid$/, ".result.json")) : "";
2010
+ }
2011
+ // Read the recorded codex exit code from a launcher result marker (null if absent/unparseable).
2012
+ function readExitCode(rf) {
2013
+ try {
2014
+ const m = String(readFileSync(rf, "utf8")).match(/__EXIT__=(-?\d+)/);
2015
+ return m ? Number(m[1]) : null;
2016
+ } catch { return null; }
2017
+ }
2018
+ // A window is "alive" if its launcher PID still exists (window still open).
2019
+ function windowAlive(win) {
2020
+ if(!win?.pidFile||!existsSync(win.pidFile))return false;
2021
+ const actual=launcherIdentity(readPidFile(win.pidFile),win.pidFile);
2022
+ return !!actual&&(!win.identity||sameProcess(win.identity,actual));
2023
+ }
2024
+ // Returns the conflictKeys of a block (never empty -> at least [<title>]).
2025
+ function blockKeys(block) {
2026
+ const k = Array.isArray(block.conflictKeys) ? block.conflictKeys.map(String) : [];
2027
+ return k.length ? k : ["__" + (block.title || "block")];
2028
+ }
2029
+
2030
+ // For each running window, add a pseudo-record so new blocks can be compared.
2031
+ function runningRecord() {
2032
+ return loadWindows().filter((w) => windowAlive(w) || freshPending(w)).map((w) => ({ title: w.title, keys: w.keys }));
2033
+ }
2034
+ // Check a new block against alive windows; return the blocking window title or null.
2035
+ function conflictBlock(block) {
2036
+ const nkeys = blockKeys(block);
2037
+ for (const w of runningRecord()) {
2038
+ if (keysOverlap(nkeys, w.keys)) return w.title;
2039
+ }
2040
+ return null;
2041
+ }
2042
+ // Check a set of keys against currently-alive windows; return the blocking window title or null.
2043
+ function conflictByKeys(keys) {
2044
+ for (const w of runningRecord()) {
2045
+ if (keysOverlap(keys, w.keys)) return w.title;
2046
+ }
2047
+ return null;
2048
+ }
2049
+ // Poll until no alive window conflicts with `keys`, or the timeout elapses. Returns true if clear.
2050
+ async function waitClear(keys, timeoutMs) {
2051
+ const start = Date.now();
2052
+ while (Date.now() - start < timeoutMs) {
2053
+ if (!conflictByKeys(keys)) return true;
2054
+ await new Promise((r) => setTimeout(r, 3000));
2055
+ }
2056
+ return false;
2057
+ }
2058
+ // 兜底:从任务文本推断"具体目标文件"作为额外冲突键。LLM 漏给 conflict_hints / depends_on 时,
2059
+ // 共享同一目标文件的独立块仍会被识别为冲突并合并为一个串行块(依赖/同目标任务绝不并行竞态)。
2060
+ // 只提取"像具体文件"的绝对路径(含扩展名),避免把裸目录(D:\temp)当目标造成过度串行、伤并发。
2061
+ function inferTargetKeys(text) {
2062
+ const keys = new Set();
2063
+ const s = String(text || "");
2064
+ const isFileLike = (p) => /[A-Za-z0-9_\-.]+\.[A-Za-z0-9]{1,5}$/.test(p);
2065
+ // Windows 绝对盘符路径
2066
+ for (const m of s.matchAll(/[A-Za-z]:[\\/][^\s"'`,;)]+/g)) {
2067
+ const p = m[0].replace(/[\\/]+$/, "");
2068
+ if (isFileLike(p)) keys.add("file:" + p);
2069
+ }
2070
+ // Unix 风格绝对路径:仅接受带目录成分的多段路径(≥2 个斜杠),且排除前导字符为字母/数字/冒号的情况——
2071
+ // 否则 “project-91/probe.mjs” 这类数字结尾目录会把 “/probe.mjs” 抽成碎片 key:多个任务写同名文件即互相撞 key,
2072
+ // normalizePlan 会把彼此独立的块静默合并成单块(并行计划坍缩为 inline)。
2073
+ for (const m of s.matchAll(/(?<![A-Za-z0-9:])\/[^\s"'`,;)]+/g)) {
2074
+ const p = m[0].replace(/[\\/]+$/, "");
2075
+ if (!isFileLike(p)) continue;
2076
+ if ((p.match(/\//g) || []).length < 2) continue; // 单段 “/file.ext” 视为碎片,不生成冲突 key
2077
+ keys.add("file:" + p);
2078
+ }
2079
+ return [...keys];
2080
+ }
2081
+ // Over-merge guard (code-level, not relying on the model):
2082
+ // - any `independent` block carrying multiple tasks -> split into one block per task.
2083
+ // - any two blocks that share a conflictKey (would conflict if parallel) -> merge into ONE linked block.
2084
+ // This makes "over-merge into a parallel block" impossible and "under-merge into a self-colliding batch" impossible.
2085
+ function normalizePlan(plan) {
2086
+ const originals=structuredClone(plan);
2087
+ const recs=originals.map((b,i)=>({key:'B'+(i+1),title:b.title,b,dependsOn:b.dependsOn||[]}));
2088
+ const ordered=levelWaves(recs).flat();
2089
+ for(const r of recs) r.keys=[...new Set([...(r.b.conflictKeys||[]),...r.b.tasks.flatMap(t=>inferTargetKeys(t.prompt||t.title||''))])];
2090
+ const groups=[];
2091
+ for(const r of ordered) {
2092
+ const hits=groups.filter(g=>g.some(x=>keysOverlap(x.keys,r.keys)));
2093
+ if(hits.length){
2094
+ // 合并诊断:块因共享冲突 key 被并组。若 key 是“/单段文件名”碎片形式,提示可能为误合并来源。
2095
+ const shared=[...new Set(hits.flat().flatMap(x=>x.keys).filter(k=>r.keys.includes(k)))];
2096
+ console.warn("[orchestrator] plan merge: blocks [" + [...new Set(hits.flat().map(x=>x.key)),r.key].join(",") + "] share conflict key(s): " + shared.join(", ") + (shared.some(k=>/^file:(\/|\\)[^/\\]+$/.test(k)) ? "(含单段文件名碎片 key,警惕同名文件导致的非预期合并)" : ""));
2097
+ }
2098
+ const group=[...hits.flat(),r];for(const hit of hits)groups.splice(groups.indexOf(hit),1);groups.push(group);
2099
+ }
2100
+ const owner=new Map();groups.forEach((g,i)=>g.forEach(r=>owner.set(r.key,i)));
2101
+ const result=groups.map((g,i)=>({title:g.length===1?g[0].title:'merged-'+(i+1),type:g.length>1||g.some(r=>r.b.type==='linked')?'linked':g[0].b.type,
2102
+ tasks:ordered.filter(r=>g.includes(r)).flatMap(r=>r.b.tasks),conflictKeys:[...new Set(g.flatMap(r=>r.keys))],
2103
+ dependsOn:[...new Set(g.flatMap(r=>r.dependsOn).filter(k=>owner.get(k)!==i).map(k=>owner.get(k)))]}));
2104
+ for(const b of result)b.dependsOn=b.dependsOn.map(i=>result[i].title);
2105
+ try{levelWaves(result.map((b,i)=>({key:'B'+(i+1),title:b.title,b,dependsOn:b.dependsOn})));return result;}
2106
+ catch(e){if(!/Cyclic/.test(e.message))throw e;return [{title:'serial-conflict-dependencies',type:'linked',dependsOn:[],conflictKeys:[...new Set(recs.flatMap(r=>r.keys))],tasks:ordered.flatMap(r=>r.b.tasks)}];}
2107
+ }
2108
+ // Stale transient auto-clean ("用完即弃"): remove orchestrator's own leftover temp artifacts
2109
+ // (prompt/launcher/result/log/pid/.self-closed markers) and dead-window pid files;
2110
+ // never remove anything belonging to a live process (registry is not the only source of truth:
2111
+ // a launcher may be alive but not yet registered, so the pid file itself is consulted).
2112
+ function pidFileOfTransient(f) {
2113
+ let m;
2114
+ if ((m = f.match(/^window-(.*)\.pid$/))) return f;
2115
+ if ((m = f.match(/^window-(.*)\.pid\.self-closed$/))) return "window-" + m[1] + ".pid";
2116
+ if ((m = f.match(/^win-(.*)\.result\.json\.self-closed$/))) return "window-" + m[1] + ".pid";
2117
+ if ((m = f.match(/^win-(.*)\.result\.json$/))) return "window-" + m[1] + ".pid";
2118
+ if ((m = f.match(/^win-launch-(.*)\.ps1$/))) return "window-" + m[1] + ".pid";
2119
+ if ((m = f.match(/^agent-win-(.*)\.md$/))) return "window-" + m[1] + ".pid";
2120
+ if ((m = f.match(/^win-(.*)\.log$/))) return "window-" + m[1] + ".pid";
2121
+ return null;
2122
+ }
2123
+ function staleTransientFiles() {
2124
+ const alivePids = new Set();
2125
+ const res = [];
2126
+ let names = [];
2127
+ try { names = readdirSync(TMP); } catch { return res; }
2128
+ // 先扫一遍全部 window-*.pid:只要进程仍存活即受保护(无论是否已入 registry)。
2129
+ for (const f of names) {
2130
+ if (!/^window-.*\.pid$/.test(f)) continue;
2131
+ try {
2132
+ const pid = Number(String(readFileSync(path.join(TMP, f), "utf8")).trim());
2133
+ if (pid > 0 && pidAliveCheck(pid)) alivePids.add(pid);
2134
+ } catch { /* unreadable -> stale */ }
2135
+ }
2136
+ for (const f of names) {
2137
+ const p = path.join(TMP, f);
2138
+ if (!/^(agent-win-.*\.md|win-launch-.*\.ps1|win-.*\.(result\.json|log)|window-.*\.pid(\.self-closed)?|win-.*\.result\.json\.self-closed)$/.test(f)) continue;
2139
+ const pidf = pidFileOfTransient(f);
2140
+ if (!pidf) continue;
2141
+ try {
2142
+ const pid = Number(String(readFileSync(path.join(TMP, pidf), "utf8")).trim());
2143
+ if (pid > 0 && alivePids.has(pid)) continue; // 活窗的附属文件一律不删
2144
+ } catch { /* pid 文件缺失/不可读 -> 残留 */ }
2145
+ res.push(p);
2146
+ }
2147
+ return res;
2148
+ }
2149
+ async function cleanStaleWindows() {
2150
+ // 兜底:上次若被中断/异常退出,会遗留 orchestrator 窗口(win-launch-*.ps1 launcher + 其 agent 子进程)。
2151
+ // 下次启动时按 orchestrator 自己的命名(win-launch-)识别历史残留,用优雅关窗关闭(杀 codex -> launcher exit0 -> WT pane 自动关)。
2152
+ // 安全边界:只清理「没有活着 pid 文件 / 未被 registry 追踪 / 当前没有 pending 占位」的 launcher——
2153
+ // 绝不误杀同一或另一编排批次仍在运行的窗口(活窗应走冲突锁,而不是被“陈旧清理”干掉)。
2154
+ try {
2155
+ const freshReservations = loadWindows().filter(freshPending);
2156
+ if (freshReservations.length) {
2157
+ console.log("[orchestrator] another process is mid-popup (" + freshReservations.length + " pending reservation(s)); skipping stale-window cleanup.");
2158
+ return;
2159
+ }
2160
+ const protectedPids = new Set();
2161
+ let names = [];
2162
+ try { names = readdirSync(TMP); } catch { names = []; }
2163
+ for (const f of names) {
2164
+ if (!/^window-.*\.pid$/.test(f)) continue;
2165
+ try {
2166
+ const pid = Number(String(readFileSync(path.join(TMP, f), "utf8")).trim());
2167
+ if (pid > 0 && pidAliveCheck(pid)) protectedPids.add(pid);
2168
+ } catch { /* ignore */ }
2169
+ }
2170
+ for (const w of loadWindows()) {
2171
+ if (windowAlive(w) && w.pidFile && existsSync(w.pidFile)) {
2172
+ try { protectedPids.add(Number(String(readFileSync(w.pidFile, "utf8")).trim())); } catch { /* ignore */ }
2173
+ }
2174
+ }
2175
+ const r = spawnSync(PS_EXE, ["-NoProfile","-ExecutionPolicy","Bypass","-Command",
2176
+ "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*win-launch-*' } | ForEach-Object { $_.ProcessId }"], { encoding: "utf8", windowsHide: true });
2177
+ const ids = String(r.stdout || "").split(/\r?\n/).map((s) => Number(String(s).trim())).filter((n) => Number.isFinite(n) && n > 0 && !protectedPids.has(n));
2178
+ if (!ids.length) { console.log("[orchestrator] no stale orchestrator windows from previous run."); return; }
2179
+ console.log("[orchestrator] cleaning " + ids.length + " stale orchestrator window(s) from previous run (protected live windows: " + protectedPids.size + ")...");
2180
+ for (const pid of ids) { try { await closeWindowByPid(pid); } catch { /* ignore */ } }
2181
+ } catch { /* ignore */ }
2182
+ }
2183
+ function cleanStaleTransients() {
2184
+ try {
2185
+ const stale = staleTransientFiles();
2186
+ for (const f of stale) rmSync(f, { force: true });
2187
+ if (stale.length) console.log("[orchestrator] cleaned " + stale.length + " stale transient file(s)");
2188
+ } catch { /* ignore */ }
2189
+ }
2190
+ // --status: print alive windows, pending locks, auto-release locks whose window closed.
2191
+ // readonly=true 仅用于外部只读查询(如 poll-windows),不释放/改写锁,避免与运行中的编排器竞争。
2192
+ function showStatus(readonly) {
2193
+ const wins = loadWindows();
2194
+ const alive = wins.filter((w) => windowAlive(w));
2195
+ const closed = wins.filter((w) => !windowAlive(w));
2196
+ let locks = loadLocks();
2197
+ const before = locks.length;
2198
+ locks = locks.filter((l) => { const bw = alive.find((w) => w.title === l.blockedBy); return !!bw; });
2199
+ if (!readonly && locks.length !== before) { updateLocks(() => { const cur = loadJson(LOCK_REG, []); return cur.filter((l) => { const bw = (loadWindows()).find((w) => w.title === l.blockedBy); return !!bw; }); }); console.log("released " + (before - locks.length) + " lock(s) whose window closed"); }
2200
+ console.log("=== alive agent TUI windows ===");
2201
+ for (const w of alive) console.log(" [" + w.title + "] keys=" + (w.keys || []).join(","));
2202
+ if (!alive.length) console.log(" (none)");
2203
+ console.log("=== pending window reservations (mid-popup) ===");
2204
+ const pending = wins.filter(freshPending);
2205
+ for (const w of pending) console.log(" [reserve:" + w.title + "] keys=" + (w.keys || []).join(","));
2206
+ if (!pending.length) console.log(" (none)");
2207
+ console.log("=== pending locks (waiting for a window to close) ===");
2208
+ for (const l of locks) console.log(" [blocked] \"" + (l.title || l.block) + "\" waits for window \"" + l.blockedBy + "\"");
2209
+ if (!locks.length) console.log(" (none)");
2210
+ console.log("=== active runs ===");
2211
+ let boardRows = [];
2212
+ try {
2213
+ const runsDir = path.join(STATE_DIR, "runs");
2214
+ for (const d of readdirSync(runsDir)) {
2215
+ try {
2216
+ const sum = JSON.parse(readFileSync(path.join(runsDir, d, "summary.json"), "utf8").replace(/^\uFEFF/, ""));
2217
+ if (sum && !sum.finishedAt) boardRows.push(boardSummaryLine(sum));
2218
+ } catch { /* mid-write or foreign dir */ }
2219
+ }
2220
+ } catch { /* no runs dir yet */ }
2221
+ if (!boardRows.length) console.log(" (no active runs)");
2222
+ else for (const row of boardRows) console.log(" " + row);
2223
+ }
2224
+ // ---------------------------------------------------------------------------
2225
+ // CLI
2226
+ // ---------------------------------------------------------------------------
2227
+ // 入口判定(真实执行 vs 只读分析):明显两端走确定性快判,歧义交给 LLM(更准)。
2228
+ // fail-safe:LLM 失败/无结论 -> 判 execute(宁可弹窗,也不把真实执行误当只读而跳过)。
2229
+ async function classifyExecutionMode(request) {
2230
+ const req = String(request || "").trim();
2231
+ if (!req) return "inline";
2232
+ const RO_RE = /(分析|方案|看看|说明|回答|解读|介绍|评估|为什么|怎么回事|怎样|如何|是否|规划|理解|解释|回顾|概述|总结|探讨|讨论|这是什么|怎么理解|报告一下|列表|[??]\s*$)/;
2233
+ const REAL_RE = /(创建|写入|写文件|修改|改|删除|删|复制|启动|重启|生成|新增|优化|修复|清理|删掉|建立|添加|安装|测试|执行|部署|停止|停|重命名|迁移|解耦|还原|配置|编辑|刷新|重置|连接|运行|编译|构建|打包|发布|提交|推送|发起|build|write|delete|create|fix|install|restart|start|stop|restore|migrate|deploy|run|edit)/i;
2234
+ const ro = RO_RE.test(req);
2235
+ const real = REAL_RE.test(req);
2236
+ if (real && !ro) return "execute";
2237
+ if (ro && !real) return "inline";
2238
+ // 歧义(同时命中或都不命中)→ LLM 判定,更准。
2239
+ const prompt = "你只做一件事:判断下面这段请求是「只读/纯分析(不修改文件、不安装、不构建、不运行、不配置,只是看/解释/评估/方案/回答)」还是「真实执行(会写文件/改代码/安装/构建/运行/配置/重启/删除等)」。只回一个词:inline 或 execute,不要任何其它文字。\n请求:" + req;
2240
+ try {
2241
+ const res = await runThreadPrompt(prompt, { sandboxMode: "read-only" });
2242
+ const ans = String((res && res.final) || "");
2243
+ if (/execute|真实|执行|修改|写入|安装|构建|运行|部署|重启|删除/.test(ans)) return "execute";
2244
+ if (/inline|只读|分析|评估|查看|方案|回答|说明/.test(ans)) return "inline";
2245
+ } catch { /* ignore */ }
2246
+ return "execute"; // fail-safe
2247
+ }
2248
+ export async function main() {
2249
+ if (ORCH_ARG_ERROR) throw new Error(ORCH_ARG_ERROR);
2250
+ for(const [flag,key] of [["--run-id","ORCH_RUN_ID"],["--acceptance-file","ORCH_ACCEPTANCE_FILE"],["--acceptance","ORCH_ACCEPTANCE_FILE"]]){const index=process.argv.indexOf(flag);if(index>=0){if(!process.argv[index+1])throw new Error(flag+" needs a value");process.env[key]=process.argv[index+1];process.argv.splice(index,2);}}
2251
+ const modeArgIndex=process.argv.indexOf("--mode");if(modeArgIndex>=0){if(!process.argv[modeArgIndex+1])throw new Error("--mode needs a value");process.env.ORCH_SPAWN_MODE=process.argv[modeArgIndex+1];process.argv.splice(modeArgIndex,2);}
2252
+ const forceIdx=process.argv.indexOf("--force-exec");if(forceIdx>=0){process.env.ORCH_FORCE_EXECUTE='1';process.argv.splice(forceIdx,1);}
2253
+ const mode = process.argv[2];
2254
+ // 维护锁:上锁期间一切动作模式(--run-windows/--spawn/--resume-run/--run/--exec/--plan/
2255
+ // --selftest-close 等)只回固定话术,发起 agent 本轮跳过编排器直接执行;只读模式不受影响。
2256
+ const MAINTENANCE_BYPASS = new Set([undefined, "--doctor", "--status", "--watch", "--verify-run", "--clear-cache", "--tasks", "--install-deps"]);
2257
+ if (lockedForMaintenance() && !MAINTENANCE_BYPASS.has(mode)) {
2258
+ console.log(MAINTENANCE_MESSAGE);
2259
+ return;
2260
+ }
2261
+ if (mode === "--doctor") {
2262
+ console.log(JSON.stringify({ agent: AGENT.agent, cli: CODEX, workspace: WORKSPACE,
2263
+ stateDir: STATE_DIR, terminal: wtAvailable() ? "wt" : "conhost",
2264
+ spawnMode: normalizeSpawnMode(process.env.ORCH_SPAWN_MODE) || "headless",
2265
+ unit: AGENT.config.unit ? "caller" : AGENT.config.profile ? "profile" : "adapter",
2266
+ probe: AGENT.config.unit ? "none" : "yes",
2267
+ carrier: AGENT_ERROR ? null : unitCarrier(),
2268
+ carrierAvailable: ptyAvailability().ok,
2269
+ hostError: AGENT_ERROR ? AGENT_ERROR.message : null,
2270
+ args: AGENT_ARGS_LIST, interactiveOutput: "inherited" }, null, 2));
2271
+ return;
2272
+ }
2273
+ if (mode === "--tasks") {
2274
+ // 只读助手:打印权威账本(T001…),任何调用方据此对齐 id 后再写计划文件。
2275
+ const request = process.argv.slice(3).join(" ");
2276
+ console.log(JSON.stringify({ schema: 1, kind: "tasks", tasks: requestTasks(request) }, null, 2));
2277
+ return;
2278
+ }
2279
+ if (mode === "--plan") {
2280
+ const request = process.argv.slice(3).join(" ");
2281
+ checkDiskSpace({ warnOnly: true });
2282
+ if (PLAN_FILE) {
2283
+ // 计划文件模式:纯确定性,零 LLM(不读缓存、不写缓存、不调模型)。
2284
+ const capacity = computeMaxWindows(os.totalmem() / 1024 ** 3);
2285
+ const effective = ledgerEffectivePlan(request, planFromFile(request, loadPlanFile(PLAN_FILE), capacity), capacity);
2286
+ console.log(JSON.stringify({ blocks: effective }, null, 2));
2287
+ return;
2288
+ }
2289
+ requireHost();
2290
+ const plan = await decomposeSafely(request);
2291
+ console.log(JSON.stringify({ blocks: plan }, null, 2));
2292
+ return;
2293
+ }
2294
+ if (mode === "--clear-cache") {
2295
+ try { rmSync(DECOMPOSE_CACHE, { force: true }); console.log("[orchestrator] decompose cache cleared: " + DECOMPOSE_CACHE); }
2296
+ catch (e) { console.log("[orchestrator] nothing to clear (cache absent or unreadable): " + (e && e.message ? e.message : e)); }
2297
+ return;
2298
+ }
2299
+ if (mode === "--spawn") {
2300
+ requireHost();
2301
+ armEnforcement();
2302
+ ensureCodexCli();
2303
+ if (!checkDiskSpace()) { process.exitCode = 1; return; }
2304
+ const title = process.argv[3] || "codex-task";
2305
+ let promptContent = process.argv.slice(4).join(" ");
2306
+ const probablyFile = process.argv[4];
2307
+ if (probablyFile && probablyFile.toLowerCase().endsWith(".md") && existsSync(probablyFile)) {
2308
+ promptContent = readFileSync(probablyFile, "utf8");
2309
+ }
2310
+ const rid = Date.now().toString(36);
2311
+ const trackedPrompt = promptContent + "\n\n【本块唯一标识】spawn-" + rid + (AGENT.agent!=='codex'?"\n完成后最后只输出 __ORCH_DONE__ spawn-"+rid:"\n(最终汇报请以「【本块唯一标识】spawn-" + rid + " 完成」开头,并说明本块完成情况)");
2312
+ const { lp, pidf, pf, rf } = writeInteractiveLauncher(title, trackedPrompt, rid);
2313
+ const stok = "spawn-" + rid;
2314
+ const claim = { id: "claim-" + rid + "-spawn", title: titleOf("spawn-" + rid, title), keys: ["spawn:" + rid], pidFile: pidf, pid: 0, locked: false, pending: true, reservedAt: Date.now() };
2315
+ const claimBlocker = tryReserveWindow(claim);
2316
+ if (claimBlocker) {
2317
+ console.log("spawn REFUSED: atomic claim blocked by [" + claimBlocker + "]; not opening another window.");
2318
+ for (const f of [pf, lp]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
2319
+ return;
2320
+ }
2321
+ const popped = await spawnWindowWithRetry(lp, title, pidf, 3, stok);
2322
+ if (!popped) {
2323
+ cancelReservation(claim.id);
2324
+ for (const f of [pf, lp, pidf, rf, pidf + ".self-closed", rf + ".self-closed"]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
2325
+ console.log("spawn POPUP-FAILED: window did not appear.");
2326
+ return;
2327
+ }
2328
+ commitReservation(claim.id, readPidFile(pidf) || 0);
2329
+ console.log("write launcher -> " + lp);
2330
+ console.log("spawned tracked TUI window, polling for completion...");
2331
+ const matchToken = "【本块唯一标识】spawn-" + rid;
2332
+ const sessionFile = AGENT.agent!=='codex'?rf:await waitForSessionFile(matchToken, 120000, rf);
2333
+ let res = { done: false, text: "" };
2334
+ if (sessionFile) {
2335
+ res = AGENT.agent!=='codex'
2336
+ ? await waitAdapterResult(rf,POLL_TIMEOUT_MS,pidf)
2337
+ : await waitTaskComplete(sessionFile, POLL_TIMEOUT_MS, rf, matchToken, pidf);
2338
+ } else if (existsSync(rf)) {
2339
+ res = { done: true, text: "(launcher exit marker seen; no rollout matched)", exitCode: readExitCode(rf) };
2340
+ }
2341
+ const summaryPath = path.join(TMP, "win-summary.json");
2342
+ let summary = {};
2343
+ try { summary = JSON.parse(readFileSync(summaryPath, "utf8")); } catch {}
2344
+ summary["spawn-" + rid] = { title, status: res.done ? "done" : "timeout", exitCode: res.exitCode ?? null, result: (res.text || "(no result captured)").trim().slice(0, 2000) };
2345
+ writeFileSync(summaryPath, JSON.stringify(summary, null, 2), "utf8");
2346
+ console.log("=== spawn result ===");
2347
+ console.log("done=" + res.done);
2348
+ console.log("final=" + trunc(res.text || "(none)", 500));
2349
+ // Wait for launcher to exit on its own, then force close if needed.
2350
+ if (res.done) {
2351
+ const pid = Number(String(readFileSync(pidf, "utf8")).trim());
2352
+ let alive = true;
2353
+ const t0 = Date.now();
2354
+ while (Date.now() - t0 < 15000) {
2355
+ try { process.kill(pid, 0); } catch (e) { if (!e || e.code !== "EPERM") { alive = false; break; } }
2356
+ await new Promise((r) => setTimeout(r, 200));
2357
+ }
2358
+ if (alive) { await closeWindow(pidf); }
2359
+ await new Promise((r) => setTimeout(r, Number(process.env.ORCH_CLOSE_DELAY_MS) || 6000));
2360
+ cancelReservation(claim.id);
2361
+ for (const fp of [pidf, lp, pf, rf, pidf + ".self-closed", rf + ".self-closed"]) { try { if (existsSync(fp)) rmSync(fp, { force: true }); } catch {} }
2362
+ } else {
2363
+ console.log("spawn window still running (manual interactive); registry record kept for tracking/enforcement.");
2364
+ }
2365
+ return;
2366
+ }
2367
+ if (mode === "--selftest-close") {
2368
+ // Verification helper for the graceful auto-close fix: open ONE real interactive window with a
2369
+ // trivial prompt, wait for task_complete in its rollout, then let the launcher's own watchdog
2370
+ // self-close it (~1s) and confirm the launcher actually exits 0 (=> the pane auto-closes, no
2371
+ // stuck "[exit code 1]" pane, and no orphan agent is left behind). closeWindow() is only a
2372
+ // fallback if the launcher is still alive after the timeout. Run by the authorized window for the
2373
+ // required "弹一个窗确认约 1 秒后自动关、无残留进程".
2374
+ requireHost();
2375
+ armEnforcement();
2376
+ cleanStaleTransients();
2377
+ const title = process.argv[3] || "selftest-close";
2378
+ const rid = Date.now().toString(36);
2379
+ const prompt =
2380
+ "这是 open-tui-orchestrator 的自动关闭自检窗。请只回复四个字:“自检OK”。不要执行任何其它动作,回复完即结束本窗。\n\n" +
2381
+ "【本块唯一标识】selftest-" + rid + "\n(最终汇报请以「【本块唯一标识】selftest-" + rid + " 完成」开头,并说明本块完成情况)";
2382
+ const { lp, pidf, pf, rf } = writeInteractiveLauncher("selftest", prompt, rid);
2383
+ const claim = { id: "claim-" + rid + "-selftest", title: "selftest-close", keys: ["selftest:" + rid], pidFile: pidf, pid: 0, locked: false, pending: true, reservedAt: Date.now() };
2384
+ const claimBlocker = tryReserveWindow(claim);
2385
+ if (claimBlocker) {
2386
+ console.log("selftest REFUSED: atomic claim blocked by [" + claimBlocker + "].");
2387
+ for (const f of [pf, lp]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
2388
+ return;
2389
+ }
2390
+ const popped = await spawnWindowWithRetry(lp, "selftest-close", pidf, 3, "selftest-" + rid);
2391
+ if (!popped) {
2392
+ cancelReservation(claim.id);
2393
+ for (const f of [pf, lp, pidf, rf, pidf + ".self-closed", rf + ".self-closed"]) { try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* ignore */ } }
2394
+ console.log("selftest POPUP-FAILED: window did not appear.");
2395
+ return;
2396
+ }
2397
+ commitReservation(claim.id, readPidFile(pidf) || 0);
2398
+ const matchToken = "【本块唯一标识】selftest-" + rid;
2399
+ console.log("selftest window spawned (pidfile=" + pidf + "), waiting for task_complete...");
2400
+ let res = { done: false, text: "" };
2401
+ const sessionFile = await waitForSessionFile(matchToken, 120000, rf);
2402
+ if (sessionFile) res = await waitTaskComplete(sessionFile, 180000, rf, matchToken, pidf);
2403
+ else if (existsSync(rf)) res = { done: true, text: "(launcher exit marker seen; no rollout matched)", exitCode: readExitCode(rf) };
2404
+ const tComplete = Date.now();
2405
+ console.log("=== selftest-close result ===");
2406
+ console.log("sessionFile=" + sessionFile);
2407
+ console.log("done=" + res.done);
2408
+ console.log("final=" + trunc(res.text || "(none)", 200));
2409
+ const pid = Number(String(readFileSync(pidf, "utf8")).trim());
2410
+ // Wait for the LAUNCHER to exit on its own (watchdog self-close, expect ~1s after completion).
2411
+ // If it is still alive after 15s, fall back to the graceful closeWindow() (last resort cleanup).
2412
+ let launcherAlive = true;
2413
+ const tWait = Date.now();
2414
+ while (Date.now() - tWait < 15000) {
2415
+ try { process.kill(pid, 0); } catch (e) { if (!e || e.code !== "EPERM") { launcherAlive = false; break; } }
2416
+ await new Promise((r) => setTimeout(r, 200));
2417
+ }
2418
+ if (launcherAlive) {
2419
+ console.log(" !! launcher still alive after task_complete; calling graceful closeWindow() fallback...");
2420
+ await closeWindow(pidf);
2421
+ try { process.kill(pid, 0); launcherAlive = true; } catch (e) { if (!e || e.code !== "EPERM") launcherAlive = false; }
2422
+ }
2423
+ const tClosed = Date.now();
2424
+ const elapsedAfterDetectMs = tClosed - tComplete;
2425
+ // Settle briefly so a just-exiting watchdog job can't count as an orphan.
2426
+ await new Promise((r) => setTimeout(r, 1500));
2427
+ let orphanAgent = false;
2428
+ try {
2429
+ const spid = Number(pid); const kids = spawnSync(PS_EXE, ["-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"ParentProcessId=" + (Number.isFinite(spid) ? spid : -1) + "\" | Measure-Object | Select-Object -ExpandProperty Count"], { windowsHide: true, encoding: "utf8" });
2430
+ orphanAgent = Number(String(kids.stdout || "0").trim()) > 0;
2431
+ } catch { /* ignore */ }
2432
+ console.log("launcherPid=" + pid + " launcherStillAlive=" + launcherAlive + " (expect false => pane auto-closed)");
2433
+ console.log("elapsedAfterDetectMs=" + elapsedAfterDetectMs + " (orchestrator-detect -> launcher gone; launcher watchdog itself polls every 500ms, expect ~1s from task_complete)");
2434
+ console.log("orphanChildren=" + orphanAgent + " (expect false => no orphan agent)");
2435
+ console.log("=== selftest-close done ===");
2436
+ cancelReservation(claim.id);
2437
+ for (const f of [pf, lp, pidf, rf, pidf + ".self-closed", rf + ".self-closed"]) { if (existsSync(f)) { try { rmSync(f, { force: true }); } catch { /* ignore */ } } }
2438
+ return;
2439
+ }
2440
+ if (mode === '--verify-run') {
2441
+ const id=process.argv[3]||process.env.ORCH_RUN_ID;
2442
+ process.exitCode=verifyRun(WORKSPACE,id,process.env.ORCH_ACCEPTANCE_FILE)?0:1;return;
2443
+ }
2444
+ if (mode === '--install-deps') {
2445
+ // 公开安装路径的补依赖入口:npm 发行包里不带锁定 SDK 依赖(node-pty + codex-sdk),
2446
+ // 用户装完包跑这一条即可,不需要自己拼路径。只装本包自己的依赖,不动任何 agent CLI;
2447
+ // 已装则直接回报(幂等、不联网)。stdout 保持纯数据(进度与失败原因都走 stderr)。
2448
+ // 实现只有一份、且在 scripts/install-deps.mjs:它不依赖引擎(引擎缺依赖时加载不起来),
2449
+ // 壳层在缺依赖场景下也走同一份实现。
2450
+ const { installDepsCli } = await import('../install-deps.mjs');
2451
+ process.exitCode = installDepsCli();
2452
+ return;
2453
+ }
2454
+ if (mode === '--run-windows' || mode === '--resume-run') {
2455
+ requireHost();
2456
+ const resume=mode==='--resume-run';
2457
+ const request=resume?'':process.argv.slice(3).join(' ');
2458
+ if(resume)process.env.ORCH_RUN_ID=process.argv[3]||process.env.ORCH_RUN_ID;
2459
+ if(resume){
2460
+ // 调用方自助通道:单元命令必须与保存的运行一致(防止换命令重放副作用)。
2461
+ try{
2462
+ const savedSum=JSON.parse(readFileSync(path.join(STATE_DIR,'runs',String(process.env.ORCH_RUN_ID||''),'summary.json'),'utf8').replace(/^\uFEFF/,''));
2463
+ const savedUnit=savedSum?.unitProfile||null, givenUnit=AGENT.config.unit||null;
2464
+ if(savedUnit&&!givenUnit)throw new Error('This run used a caller-supplied unit command; re-supply the identical --unit-cmd/--unit-cmd-file to resume');
2465
+ if(savedUnit&&givenUnit&&JSON.stringify(savedUnit)!==JSON.stringify(givenUnit))throw new Error('Resume unit command differs from the saved run; refusing to resume');
2466
+ }catch(error){if(/unit command/.test(error.message||''))throw error;}
2467
+ let saved=null;try{saved=normalizeSpawnMode(JSON.parse(readFileSync(path.join(STATE_DIR,'runs',String(process.env.ORCH_RUN_ID||''),'summary.json'),'utf8')).spawnMode);}catch{}
2468
+ const requested=normalizeSpawnMode(process.env.ORCH_SPAWN_MODE);
2469
+ if(requested&&saved&&requested!==saved)console.warn('[orchestrator] spawn mode override ignored on resume (run fixed at '+saved+' mode)');
2470
+ SPAWN_MODE=saved||requested||'window';
2471
+ }else{
2472
+ SPAWN_MODE=normalizeSpawnMode(process.env.ORCH_SPAWN_MODE)||'headless';
2473
+ }
2474
+ console.log('[orchestrator] spawn mode: '+SPAWN_MODE);
2475
+ const FORCE_EXECUTE=process.env.ORCH_FORCE_EXECUTE==='1';
2476
+ if(FORCE_EXECUTE)console.log('[orchestrator] force-execute: inline gates bypassed (single/serial/read-only/self-mod still spawn units); maintenance lock still applies');
2477
+ if(!resume&&!request)throw new Error('--run-windows needs a request');
2478
+ if(!FORCE_EXECUTE&&isOrchestratorSelfMod(request)){console.log(JSON.stringify({mode:'inline',reason:'Modify the orchestrator in the initiating conversation before opening windows'}));return;}
2479
+ // 计划文件模式:调用方已显式给出计划并要求编排——跳过只读定性(零模型调用);自改文本门与容量门仍在。
2480
+ if(!resume&&!FORCE_EXECUTE&&!PLAN_FILE&&await classifyExecutionMode(request)==='inline'){console.log(JSON.stringify({mode:'inline',reason:'Read-only analysis'}));return;}
2481
+ const capacity=computeMaxWindows(os.totalmem()/1024**3);
2482
+ if(!resume&&capacity<2&&!FORCE_EXECUTE){
2483
+ console.log(JSON.stringify({mode:'inline',reason:'capacity-below-two-windows',capacity,request,tasks:requestTasks(request),next:'在发起对话直接执行原始请求;不要再次调用 --run-windows'}));
2484
+ return;
2485
+ }
2486
+ if(!checkDiskSpace()) {process.exitCode=1;return;}
2487
+ // 载体就绪门:pty 载体的引擎必须在启动任何单元前可解析(缺失立即报错,不让单元静默秒退)。
2488
+ if(SPAWN_MODE==='headless'){try{assertUnitCarrierReady();}catch(error){console.error(error.message);process.exitCode=1;return;}}
2489
+ let preflightPlan;
2490
+ if(!resume){
2491
+ preflightPlan=PLAN_FILE?ledgerEffectivePlan(request,planFromFile(request,loadPlanFile(PLAN_FILE),capacity),capacity):await decomposeSafely(request,capacity);
2492
+ const policy=windowExecutionPolicy(preflightPlan);
2493
+ if(policy.mode==='inline'&&!FORCE_EXECUTE){
2494
+ console.log(JSON.stringify({mode:'inline',reason:policy.reason,capacity,request,tasks:requestTasks(request),blocks:preflightPlan.map(b=>({title:b.title,type:b.type,dependsOn:b.dependsOn||[],tasks:b.tasks||[]})),next:'在发起对话直接执行原始请求;不要再次调用 --run-windows'}));
2495
+ return;
2496
+ }
2497
+ if(policy.mode==='inline')console.log('[orchestrator] force-execute: inline gate bypassed ('+policy.reason+'); spawning unit(s) anyway');
2498
+ }
2499
+ armEnforcement();
2500
+ const result=await runWindows(request,{
2501
+ plan:decomposeSafely,compact:capPlanBlocks,infer:inferTargetKeys,launcher:writeInteractiveLauncher,prompt:buildBlockPrompt,
2502
+ spawn:(...args)=>(SPAWN_MODE==="headless"?spawnHeadlessWithRetry:spawnWindowWithRetry)(...args,true),readPid:readPidFile,
2503
+ session:AGENT.agent!=='codex'?async(_token,_timeout,rf)=>rf:waitForSessionFile,
2504
+ wait:AGENT.agent!=='codex'?(async(_session,timeout,rf,_token,pidf)=>waitAdapterResult(rf,timeout,pidf)):waitTaskComplete,exitCode:readExitCode,
2505
+ register:r=>updateWindows(ws=>[...ws,r]),commit:commitReservation,unregister:cancelReservation,close:closeWindowByPid,
2506
+ guardian:g=>ensureRunGuardian({...g,agent:AGENT.agent,env:process.env}),
2507
+ cleanup:la=>{const wsRoot=(()=>{const r=path.resolve(WORKSPACE);return r.endsWith(path.sep)?r:r+path.sep;})();for(const f of [la.lp,la.pf,la.pidf,la.rf,la.pidf+'.self-closed',la.rf+'.self-closed',String(la.lp||'').replace(/\.ps1$/,'.pty.log')])try{const resolved=path.resolve(String(f||''));if(resolved.startsWith(wsRoot))rmSync(resolved,{force:true});}catch{}}
2508
+ },{workspace:WORKSPACE,capacity,resume,agent:AGENT.agent,unitProfile:AGENT.config.unit||null,modelPlan:MODEL_PLAN,runId:process.env.ORCH_RUN_ID,acceptanceFile:process.env.ORCH_ACCEPTANCE_FILE,timeoutMs:POLL_TIMEOUT_MS,auth:readAuthPrompt(),plan:preflightPlan,spawnMode:SPAWN_MODE,spawnStaggerMs:Math.max(0,Number(process.env.ORCH_WINDOW_STAGGER_MS||0)||0),windowCap:Math.max(0,Number(process.env.ORCH_MAX_WINDOWS||0)||0)||undefined});
2509
+ process.exitCode=result.success?0:1;return;
2510
+ }
2511
+
2512
+ if (mode === "--watch") {
2513
+ const id = process.argv[3] || process.env.ORCH_RUN_ID;
2514
+ if (!id) throw new Error("--watch needs a run id");
2515
+ const jsonl = process.argv.includes("--jsonl");
2516
+ const ivRaw = process.argv.indexOf("--interval-ms");
2517
+ const iv = ivRaw >= 0 ? Number(process.argv[ivRaw + 1]) : NaN;
2518
+ process.exitCode = await watchRun({ workspace: WORKSPACE, runId: id, intervalMs: Number.isFinite(iv) && iv > 0 ? iv : 800, jsonl });
2519
+ return;
2520
+ }
2521
+ if (mode === "--status") {
2522
+ const mlock = readMaintenanceLock();
2523
+ if (mlock.locked) console.log("maintenance lock: LOCKED" + (mlock.reason ? " (" + mlock.reason + ")" : "") + (mlock.expiresAt ? ", expires " + new Date(mlock.expiresAt).toISOString() : "") + " - " + MAINTENANCE_MESSAGE);
2524
+ showStatus(process.argv.includes("--readonly"));
2525
+ return;
2526
+ }
2527
+ if (mode === "--run" || mode === "--exec") {
2528
+ requireHost();
2529
+ if(AGENT.agent!=='codex')throw new Error('This agent uses --run-windows; headless execution is not implemented');
2530
+ // Enforcement: headless real execution is hard-disabled by default. The sanctioned engine for
2531
+ // REAL work is --run-windows (a real visible TUI window). Opt-in via ORCH_ALLOW_HEADLESS=1 only
2532
+ // for pure, windowless automation that the user has explicitly sanctioned.
2533
+ if (process.env.ORCH_ALLOW_HEADLESS !== "1") {
2534
+ console.error("[orchestrator] REFUSED: headless --run/--exec is disabled by open-tui-orchestrator enforcement. Real work must go through --run-windows (a real, visible agent TUI window). Set ORCH_ALLOW_HEADLESS=1 only for pure automation you explicitly want windowless.");
2535
+ process.exitCode = 1;
2536
+ return;
2537
+ }
2538
+ const request = process.argv.slice(3).join(" ");
2539
+ if (!request) throw new Error("--run needs a request");
2540
+ console.log("[orchestrator] decomposing...");
2541
+ const plan = await decomposeSafely(request);
2542
+ console.log("[orchestrator] blocks=" + plan.length);
2543
+ const summary = {};
2544
+ for (let i = 0; i < plan.length; i++) {
2545
+ summary["B" + (i + 1)] = { title: plan[i].title, type: plan[i].type, status: "pending" };
2546
+ }
2547
+ const runBlock = async (b, idx) => {
2548
+ const key = idx;
2549
+ const attempt = async (retry) => {
2550
+ const body = retry ? "(上一轮执行可能失败,请先检查环境与路径,换一种更稳妥的方式完成。)\n" + blockBody(b) : blockBody(b);
2551
+ const res = await runThreadPrompt(body);
2552
+ console.log("=== " + key + " " + b.title + (retry ? " (重试)" : "") + " ===");
2553
+ console.log(trunc(res.final, 2000));
2554
+ if (!res.errored) { summary[key].status = "done"; summary[key].result = res.final; return true; }
2555
+ summary[key].status = "failed"; summary[key].result = res.errored;
2556
+ return false;
2557
+ };
2558
+ if (await attempt(false)) return;
2559
+ console.log(" !! 失败,自动重试: " + summary[key].result);
2560
+ const ok = await attempt(true);
2561
+ if (ok) summary[key].status = "done-retried";
2562
+ };
2563
+ await Promise.all(plan.map((b, i) => runBlock(b, "B" + (i + 1))));
2564
+ mkdirSync(TMP, { recursive: true });
2565
+ const out = path.join(TMP, "win-summary.json");
2566
+ writeFileSync(out, JSON.stringify(summary, null, 2), "utf8");
2567
+ console.log("[orchestrator] summary -> " + out);
2568
+ console.log(JSON.stringify(summary, null, 2));
2569
+ // 用完即删:汇总已打印到 stdout;默认删除本批临时产物,ORCH_KEEP_TEMP=1 可保留供排查
2570
+ if (process.env.ORCH_KEEP_TEMP !== "1") {
2571
+ try { rmSync(out, { force: true }); console.log("[orchestrator] temp artifacts cleaned (used-and-discarded)"); } catch { /* ignore */ }
2572
+ }
2573
+ return;
2574
+ }
2575
+ console.log(
2576
+ "Usage:\n" +
2577
+ " node orchestrate-sdk.mjs --plan \"<request>\"\n" +
2578
+ " node orchestrate-sdk.mjs --run \"<request>\"\n" +
2579
+ " node orchestrate-sdk.mjs --run-windows \"<request>\"\n" +
2580
+ " node orchestrate-sdk.mjs --spawn <title> \"<prompt or .md>\"",
2581
+ );
2582
+ }
2583
+ const isMain = process.argv[1] && path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase();
2584
+ // 机器可调用契约:公开入口的失败只回一行 `[orchestrator] FATAL: <原因>`(完整栈帧仅 ORCH_DEBUG=1)。
2585
+ // 调用方管道里永远不该出现裸 Node 栈帧——数据与诊断分得开,才谈得上「任何 agent 都能调」。
2586
+ export function reportFatal(error, stream = console.error) {
2587
+ const message = error && error.message ? error.message : String(error);
2588
+ stream("[orchestrator] FATAL: " + message);
2589
+ if (process.env.ORCH_DEBUG === "1" && error && error.stack) stream(error.stack);
2590
+ }
2591
+ if (isMain) main().catch((e) => {
2592
+ reportFatal(e);
2593
+ process.exitCode = 1;
2594
+ });
2595
+ export { classifyBlockComplexity, classifyExecutionMode, windowExecutionPolicy, appendModelEffortArgs, genericProjectSplit, trySingleBlock, conflictByKeys, loadCachedDecompose, buildBlocksFromTasks, resolveHints, extractTasks, classifyPrompt, buildBlockPrompt, levelWaves, decomposeHash, loadPlanFile, planFromFile, checkDiskSpace, readPidFile, pidAliveCheck, closeWindowByPid, windowGenuinelyPopped, extractResult, extractUsage, readReportFromResult, sessionDir, writeInteractiveLauncher, staleTransientFiles, pidFileOfTransient, tryReserveWindow, commitReservation, cancelReservation, computeMaxWindows, blockKeys, keysOverlap, scanTaskComplete, __setOrchHooks, isOrchestratorSelfMod, resolveCodexHome, withRegistryLock, spawnHeadlessWithRetry, normalizeSpawnMode, REG_LOCK as REGISTRY_LOCK_FILE };