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,107 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {spawn} from 'node:child_process';
5
+ import {expandUnitArgs} from './unit-command.mjs';
6
+ import {nativeArgumentLines} from './native-argv.mjs';
7
+
8
+ // 调用方自助通道(caller-driven)第二半:单元运行时。
9
+ // 与 print-class 画像同构的启动器契约(tee 日志 + token 行抓取 + result 文件 + self-closed 标记),
10
+ // 但 argv 完全来自调用方给的命令模板:不 probe、不解析版本、不做任何适配断言。
11
+ // 完成判定仍由主控按 token + checkpoint + 验收复检执行,绝不依赖该 CLI 的输出格式。
12
+
13
+ const quote = (s) => "'" + String(s).replaceAll("'", "''") + "'";
14
+ const TOKEN_RE = /【本块唯一标识】\s*([A-Za-z0-9][A-Za-z0-9_-]*)/;
15
+
16
+ export async function runUnitPlanner(config, prompt, cwd) {
17
+ const unit = config?.unit || config; // 宿主 config 里画像挂在 unit 下;直连测试可直接传画像
18
+ const planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orch-unit-plan-'));
19
+ const pf = path.join(planDir, 'planner-prompt.md');
20
+ fs.writeFileSync(pf, String(prompt), 'utf8');
21
+ const args = expandUnitArgs(unit, { prompt: String(prompt), promptFile: pf, workspace: cwd });
22
+ const useStdin = unit.promptDelivery === 'stdin';
23
+ try {
24
+ return await new Promise((resolve, reject) => {
25
+ const child = spawn(unit.bin, args, { cwd, env: process.env, windowsHide: true, shell: false, stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
26
+ if (useStdin && child.stdin) { try { child.stdin.end(String(prompt)); } catch { /* stdin closed early */ } }
27
+ let raw = '', errorText = '', timedOut = false;
28
+ const timeoutMs = (() => { const v = Number(process.env.ORCH_DECOMPOSE_TIMEOUT_MS); return Number.isFinite(v) && v > 0 ? v : 240000; })();
29
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, unit.timeoutMs ? Math.min(unit.timeoutMs, timeoutMs) : timeoutMs);
30
+ child.stdout.on('data', (b) => { raw += b; if (raw.length > 8000000) child.kill(); });
31
+ child.stderr.on('data', (b) => { errorText += b; });
32
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
33
+ child.on('exit', (code) => {
34
+ clearTimeout(timer);
35
+ try {
36
+ if (timedOut || code !== 0) throw new Error((timedOut ? 'PLANNER_TIMEOUT' : 'PLANNER_FAILED: exit ' + code) + (errorText ? ': ' + String(errorText).slice(0, 300) : ''));
37
+ const final = String(raw || '').trim();
38
+ if (!final) throw new Error('PLANNER_EMPTY_RESPONSE' + (errorText ? ': ' + String(errorText).slice(0, 300) : ''));
39
+ resolve({ final });
40
+ } catch (e) { reject(e); }
41
+ });
42
+ });
43
+ } finally {
44
+ try { fs.rmSync(planDir, { recursive: true, force: true }); } catch { /* best effort */ }
45
+ }
46
+ }
47
+
48
+ export function writeUnitLauncher(config, { key, prompt, suffix, workspace, temp }) {
49
+ const unit = config?.unit || config; // 宿主 config 里画像挂在 unit 下;直连测试可直接传画像
50
+ const stem = String(key + '-' + suffix).replace(/[^a-zA-Z0-9_-]/g, '-');
51
+ const token = TOKEN_RE.exec(prompt)?.[1];
52
+ if (!token) throw new Error((unit.label || unit.id) + ' unit requires a completion token');
53
+ fs.mkdirSync(temp, { recursive: true });
54
+ const lp = path.join(temp, 'win-launch-' + stem + '.ps1');
55
+ const pidf = path.join(temp, 'window-' + stem + '.pid');
56
+ const pf = path.join(temp, 'agent-win-' + stem + '.md');
57
+ const rf = path.join(temp, 'win-' + stem + '.result.json');
58
+ const log = path.join(temp, 'win-' + stem + '.out.log');
59
+ fs.writeFileSync(pf, prompt, 'utf8');
60
+ const args = expandUnitArgs(unit, { prompt, promptFile: pf, workspace });
61
+ const useStdin = unit.promptDelivery === 'stdin';
62
+ const invoke = useStdin
63
+ ? `$p | & ${quote(unit.bin)} @agentArgs 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.TargetObject } else { $_ } } | Tee-Object -FilePath $log`
64
+ : `& ${quote(unit.bin)} @agentArgs 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.TargetObject } else { $_ } } | Tee-Object -FilePath $log`;
65
+ const body = [
66
+ "$ErrorActionPreference = 'Continue'",
67
+ 'trap { exit 0 }',
68
+ `$PID | Set-Content -LiteralPath ${quote(pidf)} -Encoding UTF8`,
69
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
70
+ '$OutputEncoding = [System.Text.Encoding]::UTF8',
71
+ 'chcp 65001 | Out-Null',
72
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8',
73
+ 'Write-Host "============================================="',
74
+ 'Write-Host " ' + String(unit.label || unit.id).toUpperCase() + ' TASK RUNNING - closes automatically on completion"',
75
+ 'Write-Host "============================================="',
76
+ `Set-Location -LiteralPath ${quote(workspace)}`,
77
+ `$env:ORCH_WINDOW = '1'`,
78
+ `$token = ${quote(token)}`,
79
+ `$log = ${quote(log)}`,
80
+ `$resultFile = ${quote(rf)}`,
81
+ "function Write-Result {",
82
+ " param([int]$code,[string]$report)",
83
+ " try {",
84
+ " $json = '{\"__EXIT__\":' + $code + ',\"__DONE__\":true,\"__REPORT__\":' + (ConvertTo-Json $report -Compress) + '}'",
85
+ " $d = Split-Path -Parent $resultFile; if ($d -and -not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }",
86
+ " Set-Content -LiteralPath $resultFile -Value ($json + \"`r`n__EXIT__=\" + $code) -Encoding UTF8",
87
+ " try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }",
88
+ " } catch { }",
89
+ "}",
90
+ "$p = (Get-Content -Raw -Encoding UTF8 '" + String(pf).replace(/'/g, "''") + "').Trim()",
91
+ "if ([string]::IsNullOrWhiteSpace($p)) { Write-Result 2 ''; exit 0 }",
92
+ "$agentArgs = @(" + args.map(quote).join(',') + ')',
93
+ ...nativeArgumentLines(),
94
+ invoke,
95
+ '$code = $LASTEXITCODE',
96
+ "$report = ''",
97
+ "if (Test-Path -LiteralPath $log) {",
98
+ " $lines = Get-Content -LiteralPath $log -Encoding UTF8 -ErrorAction SilentlyContinue",
99
+ " $hit = $lines | Where-Object { $_.Contains($token) } | Select-Object -Last 1",
100
+ " if ($hit) { $report = $hit } else { $report = ($lines | Select-Object -Last 3) -join \"`n\" }",
101
+ "}",
102
+ 'Write-Result $code $report',
103
+ 'exit 0',
104
+ ].join('\r\n');
105
+ fs.writeFileSync(lp, '\uFEFF' + body, 'utf8');
106
+ return { lp, pidf, pf, rf, log };
107
+ }
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gate.mjs — the machine gate the release policy refers to, runnable from inside the repo.
4
+ *
5
+ * node scripts/gate.mjs --all # every criterion, in order
6
+ * node scripts/gate.mjs --suite # L1: the offline regression suite
7
+ * node scripts/gate.mjs --doctor # L2a: host resolution (hostError must be null)
8
+ * node scripts/gate.mjs --canary # L2b: a deliberately failing test must go red
9
+ * node scripts/gate.mjs --unknown-run # L3: an unknown run id fails closed, cleanly
10
+ * node scripts/gate.mjs --consistency # L4: version / seal / docs / suite total agree
11
+ *
12
+ * Rules that make it a gate rather than a smoke test:
13
+ * - every criterion prints command / expectation / actual;
14
+ * - a value that cannot be read counts as failure ("unknown is failure");
15
+ * - exit 0 only if every requested criterion passed; any failure exits 1;
16
+ * - L2b keeps the criteria themselves honest: it proves a red case still goes red.
17
+ *
18
+ * Environment: the suite follows the documented convention (`ORCH_AGENT` defaults to
19
+ * `codex` here; the codex CLI must be resolvable — see README "Test-env requirement").
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import {spawnSync} from 'node:child_process';
24
+ import {fileURLToPath} from 'node:url';
25
+ import {coordinatorDir} from './core/leases.mjs';
26
+
27
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const AGENT = String(process.env.ORCH_GATE_AGENT || 'codex').trim();
29
+ const args = new Set(process.argv.slice(2));
30
+ const wants = (flag) => args.has(flag) || args.has('--all') || args.size === 0;
31
+ const results = [];
32
+
33
+ function check(id, title, ok, lines) {
34
+ results.push({id, ok});
35
+ console.log(` ${id} ${title}`);
36
+ for (const line of lines) console.log(` ${line}`);
37
+ console.log(` → ${id} ${ok ? 'PASS' : 'FAIL'}`);
38
+ }
39
+
40
+ function node(argsArr, opts = {}) {
41
+ const r = spawnSync(process.execPath, argsArr, {cwd: REPO, encoding: 'utf8', windowsHide: true, timeout: opts.timeout || 900000, env: opts.env || process.env});
42
+ return {status: r.status, out: (r.stdout || '') + (r.stderr || '')};
43
+ }
44
+
45
+ // ---------------------------------------------------------------- L1: the suite
46
+ function suite() {
47
+ const files = fs.readdirSync(path.join(REPO, 'test')).filter((n) => n.endsWith('.test.mjs')).sort().map((n) => path.join('test', n));
48
+ const env = {...process.env, ORCH_AGENT: process.env.ORCH_AGENT || AGENT};
49
+ console.error(` [gate] running ${files.length} test files (this takes minutes)…`);
50
+ const r = node(['--test', '--test-concurrency=3', ...files], {env});
51
+ const num = (key) => {
52
+ const m = r.out.match(new RegExp('^ℹ ' + key + ' (\\d+)$', 'm'));
53
+ return m ? Number(m[1]) : null;
54
+ };
55
+ const pass = num('pass'), fail = num('fail'), skipped = num('skipped'), cancelled = num('cancelled');
56
+ const missing = [['pass', pass], ['fail', fail], ['skipped', skipped]].filter(([, v]) => v === null).map(([k]) => k);
57
+ if (missing.length) return check('L1', 'offline regression suite', false, [`expected: pass>0 / fail==0 / skipped==0 / exit==0`, `actual: missing ${missing.join(', ')} ⇒ unknown is failure`]);
58
+ const ok = pass > 0 && fail === 0 && skipped === 0 && r.status === 0;
59
+ check('L1', 'offline regression suite', ok, [
60
+ `expected: pass>0 / fail==0 / skipped==0 / exit==0`,
61
+ `actual: pass=${pass} fail=${fail} skipped=${skipped} cancelled=${cancelled} exit=${r.status}`,
62
+ ]);
63
+ return pass;
64
+ }
65
+
66
+ // ------------------------------------------------- L2a: host resolution (doctor)
67
+ function doctor() {
68
+ const r = node(['cli.mjs', '--agent', AGENT, '--doctor']);
69
+ const m = r.out.match(/"hostError"\s*:\s*(null|"[^"]*")/);
70
+ check('L2a', `host resolution (\`--agent ${AGENT} --doctor\`)`, !!m && m[1] === 'null', [
71
+ 'expected: "hostError": null',
72
+ `actual: hostError=${m ? m[1] : 'not found'} (exit=${r.status})`,
73
+ ]);
74
+ }
75
+
76
+ // ----------------------------------------- L2b: the criteria themselves can go red
77
+ function canary() {
78
+ const file = path.join(REPO, 'test', 'zzz-gate-canary.test.mjs');
79
+ fs.writeFileSync(file, "import test from 'node:test';\nimport assert from 'node:assert/strict';\n\ntest('gate canary (must fail)', () => { assert.equal(1, 2); });\n");
80
+ try {
81
+ const r = node(['--test', file]);
82
+ const red = r.status !== 0 && /^ℹ fail 1$/m.test(r.out);
83
+ check('L2b', 'red-capability canary (a failing test must fail)', red, [
84
+ 'expected: a deliberately failing test exits non-zero with `ℹ fail 1`',
85
+ `actual: exit=${r.status}, fail line ${/^ℹ fail 1$/m.test(r.out) ? 'present' : 'absent'}`,
86
+ ]);
87
+ } finally {
88
+ fs.rmSync(file, {force: true});
89
+ }
90
+ }
91
+
92
+ // ------------------------------------------------------ L3: unknown run, cleanly
93
+ function unknownRun() {
94
+ const id = 'nosuch-run-gate-' + Math.random().toString(16).slice(2, 8);
95
+ const r = node(['cli.mjs', '--verify-run', id]);
96
+ const frames = (r.out.match(/^\s+at /gm) || []).length;
97
+ const named = r.out.includes('Run not found');
98
+ check('L3', 'unknown run fails closed with one diagnostic line', r.status !== 0 && frames === 0 && named, [
99
+ 'expected: non-zero exit, no Node stack frames, mentions `Run not found`',
100
+ `actual: exit=${r.status}, stack frames=${frames}, Run not found=${named}`,
101
+ ]);
102
+ }
103
+
104
+ // ----------------------------------- L4: version / seal / docs / suite total agree
105
+ function consistency(suitePass) {
106
+ const read = (rel) => fs.readFileSync(path.join(REPO, rel), 'utf8');
107
+ const core = (read('scripts/core/contracts.mjs').match(/CORE_VERSION\s*=\s*'([^']+)'/) || [])[1];
108
+ const seal = JSON.parse(read('release.json'));
109
+ const files = Object.keys(seal.files || {}).length;
110
+ const runtime = Object.keys(seal.runtimeFiles || {}).length;
111
+ const lines = [`expected: CORE_VERSION == release.json.coreVersion; README / README-zh / release-policy carry the live version, file counts and suite total`,
112
+ `actual: core=${core} sealCore=${seal.coreVersion} files=${files} runtime=${runtime}`];
113
+ let ok = !!core && seal.coreVersion === core;
114
+ if (!ok) lines.push(`core version and the seal disagree ⇒ FAIL`);
115
+
116
+ const sources = {
117
+ 'README.md': [/core is ([\d.]+), with (\d+)\/(\d+) offline tests passing/, 0, 1, 2],
118
+ 'README-zh.md': [/当前通用核心为 ([\d.]+),离线回归为 (\d+)\/(\d+) 通过/, 0, 1, 2],
119
+ 'docs/release-policy.md': [/当前源码封印记录通用核心 ([\d.]+)、(\d+) 个核心文件和 (\d+) 个运行时文件。最新离线回归为 (\d+)\/(\d+) 通过/, 0, 3, 4],
120
+ };
121
+ const totals = new Set();
122
+ for (const [rel, [pattern, ci, pi, ti]] of Object.entries(sources)) {
123
+ const m = read(rel).match(pattern);
124
+ if (!m) { ok = false; lines.push(`${rel}: no version line found ⇒ FAIL`); continue; }
125
+ const g = m.slice(1);
126
+ if (g[ci] !== core) { ok = false; lines.push(`${rel}: core ${g[ci]} != ${core} ⇒ FAIL`); }
127
+ if (g[pi] !== g[ti]) { ok = false; lines.push(`${rel}: regression total is not all-green ${g[pi]}/${g[ti]} ⇒ FAIL`); }
128
+ totals.add(`${g[pi]}/${g[ti]}`);
129
+ }
130
+ if (totals.size > 1) { ok = false; lines.push(`the three documents disagree on the regression total ${[...totals].join(' ')} ⇒ FAIL`); }
131
+ else if (totals.size === 1) lines.push(`three documents agree on the regression total: ${[...totals][0]}`);
132
+
133
+ const policy = read('docs/release-policy.md').match(sources['docs/release-policy.md'][0]);
134
+ if (policy && (Number(policy[2]) !== files || Number(policy[3]) !== runtime)) { ok = false; lines.push(`documented seal counts ${policy[2]}/${policy[3]} != ${files}/${runtime} ⇒ FAIL`); }
135
+ if (suitePass !== null && totals.size === 1 && [...totals][0] !== `${suitePass}/${suitePass}`) { ok = false; lines.push(`documented total ${[...totals][0]} != measured suite ${suitePass} ⇒ FAIL`); }
136
+ else if (suitePass !== null) lines.push(`documented total matches the measured suite: ${suitePass}`);
137
+
138
+ // Resolving the coordinator also sweeps stale tickets (entry-time reclamation), so any
139
+ // ticket older than ten minutes left behind means that reclamation is broken.
140
+ let coordinator = null;
141
+ try { coordinator = coordinatorDir(REPO); } catch (error) { lines.push(`coordinator directory could not be resolved (${error.message})`); }
142
+ if (coordinator && fs.existsSync(coordinator)) {
143
+ const cutoff = Date.now() - 600000;
144
+ const stale = fs.readdirSync(coordinator).filter((n) => n.startsWith('lock-')).filter((n) => fs.statSync(path.join(coordinator, n)).mtimeMs < cutoff);
145
+ if (stale.length) { ok = false; lines.push(`${stale.length} stale mutex ticket(s) older than ten minutes survive reclamation ⇒ FAIL: ${stale.slice(0, 5).join(' ')}`); }
146
+ else lines.push('no stale mutex tickets in the coordinator directory');
147
+ } else {
148
+ lines.push('coordinator directory not present (nothing to sweep)');
149
+ }
150
+ check('L4', 'version / seal / docs / suite total cross-check', ok, lines);
151
+ }
152
+
153
+ let measuredSuite = null;
154
+ if (wants('--suite')) measuredSuite = suite();
155
+ if (wants('--doctor')) doctor();
156
+ if (wants('--canary')) canary();
157
+ if (wants('--unknown-run')) unknownRun();
158
+ if (wants('--consistency')) consistency(measuredSuite);
159
+
160
+ const failed = results.filter((r) => !r.ok).map((r) => r.id);
161
+ console.log(`ORCH_GATE: ${failed.length ? 'FAIL (' + failed.join(', ') + ')' : 'PASS'} — ${results.length} criteria`);
162
+ process.exitCode = failed.length ? 1 : 0;
@@ -0,0 +1,2 @@
1
+ // Shared implementation; do not copy logic into this adapter.
2
+ export * from './core/host-cli.mjs';
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-deps.mjs — 公开安装路径的「补依赖」引导实现(node-pty + codex-sdk)。
4
+ *
5
+ * 关键约束:它**不能依赖引擎**。引擎在 ORCH_AGENT=codex 时会顶层导入 @openai/codex-sdk,
6
+ * 缺依赖时连「装依赖」的入口都进不去(鸡生蛋)。所以本模块只依赖 node 内置模块,并且由
7
+ * 壳层在加载引擎**之前**调用(CLI 直接调用核内分发时也走这里,实现只有一份)。
8
+ *
9
+ * 语义:幂等(已装则回 already-present、不联网)、stdout 纯数据(进度与失败原因走 stderr)、
10
+ * 失败时指名手工兜底命令、只装本包自己的锁定依赖、永不触碰任何 agent CLI。
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import {spawnSync} from 'node:child_process';
15
+ import {fileURLToPath} from 'node:url';
16
+
17
+ const tail = (text) => String(text || '').trim().split(/\r?\n/).slice(-12).join('\n');
18
+
19
+ export function installDepsCli(pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) {
20
+ const scriptsDir = path.join(pkgRoot, 'scripts');
21
+ const shim = (name) => path.join(scriptsDir, 'node_modules', name, 'package.json');
22
+ // 每次现算:装前算一次的常量会把「刚装成功」误判成失败(2026-09-14 真机回归抓到)。
23
+ const shimPresent = () => fs.existsSync(shim('@openai/codex-sdk')) && fs.existsSync(shim('@lydell/node-pty'));
24
+ if (shimPresent()) {
25
+ console.log(JSON.stringify({ok: true, action: 'already-present', scriptsDir}));
26
+ return 0;
27
+ }
28
+ if (!fs.existsSync(path.join(scriptsDir, 'package-lock.json'))) {
29
+ console.error('[orchestrator] FATAL: package-lock.json is missing from ' + scriptsDir + '; reinstall the package');
30
+ return 1;
31
+ }
32
+ console.error('[orchestrator] installing locked SDK dependencies into ' + scriptsDir + ' (npm ci; network required)');
33
+ // 优先用随 node 一起装好的 npm-cli.js 直启:避免 shell:true 带来的 DEP0190 噪音,
34
+ // 也避免把路径交给 shell 拼接(scriptsDir 可能含空格)。找不到才回落到 npm.cmd + shell。
35
+ const npmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
36
+ const useCli = fs.existsSync(npmCli);
37
+ // 企业镜像/自定义 npm:ORCH_NPM_BIN 显式指定优先(.cmd/.exe/路径皆可)。
38
+ const npmBin = String(process.env.ORCH_NPM_BIN || '').trim();
39
+ const npm = npmBin
40
+ ? spawnSync(npmBin, ['ci', '--prefix', scriptsDir], {cwd: scriptsDir, encoding: 'utf8', windowsHide: true, timeout: 900000})
41
+ : (useCli
42
+ ? spawnSync(process.execPath, [npmCli, 'ci', '--prefix', scriptsDir], {cwd: scriptsDir, encoding: 'utf8', windowsHide: true, timeout: 900000})
43
+ : (process.platform === 'win32'
44
+ // 回落也避开 shell:true(DEP0190):显式走 cmd /d /s /c,由 cmd 自己解析 npm.cmd 与引号。
45
+ ? spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'npm ci --prefix "' + scriptsDir + '"'], {cwd: scriptsDir, encoding: 'utf8', windowsHide: true, timeout: 900000})
46
+ : spawnSync('npm', ['ci', '--prefix', scriptsDir], {cwd: scriptsDir, encoding: 'utf8', windowsHide: true, timeout: 900000})));
47
+ if (npm.status !== 0 || !shimPresent()) {
48
+ const missingToolchain = npm.error && ['ENOENT', 'EINVAL'].includes(npm.error.code);
49
+ console.error('[orchestrator] FATAL: dependency install failed (npm exit ' + (npm.status ?? 'null') + (npm.error ? ', ' + npm.error.message : '') + ')');
50
+ if (tail(npm.stderr)) console.error(tail(npm.stderr));
51
+ console.error('[orchestrator] hint: ' + (missingToolchain ? 'install Node.js 22+ so npm is on PATH (or point ORCH_NPM_BIN at your npm), or ' : '') + 'run it manually: npm ci --prefix "' + scriptsDir + '"');
52
+ return 1;
53
+ }
54
+ console.log(JSON.stringify({ok: true, action: 'installed', scriptsDir}));
55
+ return 0;
56
+ }
57
+
58
+ export default installDepsCli;
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * maintenance-lock — open-tui-orchestrator 维护锁 CLI。
4
+ *
5
+ * 对编排器自身做升级/优化/维护/更新等改动前先上锁;上锁期间任何 agent 调用编排器
6
+ * 都只返回一句话:open-tui-orchestrator正在维护更新,本轮跳过orchestrator直接开始执行
7
+ *
8
+ * node scripts/maintenance-lock.mjs lock [--reason "文字"] [--ttl-minutes N]
9
+ * node scripts/maintenance-lock.mjs unlock
10
+ * node scripts/maintenance-lock.mjs status
11
+ *
12
+ * 锁默认 180 分钟自动过期(上限 24h),过期/损坏均 fail-open 视为未锁;
13
+ * 路径可用 ORCH_MAINTENANCE_LOCK 覆盖(默认 ~/.open-tui-orchestrator/maintenance-lock.json)。
14
+ */
15
+ import { acquireMaintenanceLock, releaseMaintenanceLock, readMaintenanceLock, maintenanceLockPath, MAINTENANCE_MESSAGE } from './core/maintenance-lock.mjs';
16
+
17
+ const args = process.argv.slice(2);
18
+ const cmd = String(args[0] || 'status').toLowerCase();
19
+
20
+ function flagValue(name) {
21
+ const i = args.indexOf(name);
22
+ return i >= 0 ? args[i + 1] : undefined;
23
+ }
24
+
25
+ if (cmd === 'lock') {
26
+ const reason = String(flagValue('--reason') || '').trim();
27
+ const ttl = flagValue('--ttl-minutes');
28
+ const data = acquireMaintenanceLock({ reason, ttlMinutes: ttl === undefined ? undefined : Number(ttl) });
29
+ console.log('LOCKED: ' + MAINTENANCE_MESSAGE);
30
+ console.log(JSON.stringify({ lock: maintenanceLockPath(), reason: data.reason, startedAt: new Date(data.startedAt).toISOString(), expiresAt: new Date(data.expiresAt).toISOString() }, null, 2));
31
+ } else if (cmd === 'unlock') {
32
+ const had = readMaintenanceLock().locked;
33
+ releaseMaintenanceLock();
34
+ console.log(had ? 'unlocked: maintenance lock released.' : 'unlocked: no active maintenance lock.');
35
+ } else if (cmd === 'status') {
36
+ const st = readMaintenanceLock();
37
+ if (st.locked) {
38
+ console.log('LOCKED: ' + MAINTENANCE_MESSAGE);
39
+ console.log(JSON.stringify({ lock: maintenanceLockPath(), reason: st.reason, startedAt: st.startedAt ? new Date(st.startedAt).toISOString() : null, expiresAt: st.expiresAt ? new Date(st.expiresAt).toISOString() : null }, null, 2));
40
+ } else {
41
+ console.log('not locked' + (st.expired ? ' (previous lock expired and was cleaned up)' : '') + (st.invalid ? ' (invalid lock file ignored; fail-open)' : ''));
42
+ }
43
+ } else {
44
+ console.log('Usage: node scripts/maintenance-lock.mjs lock [--reason "文字"] [--ttl-minutes N] | unlock | status');
45
+ process.exitCode = 2;
46
+ }
@@ -0,0 +1,2 @@
1
+ // Shared implementation; do not copy logic into this adapter.
2
+ export * from './core/network-policy.mjs';
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * open-tui-orchestrator-force — technical enforcement gate for open-tui-orchestrator.
4
+ *
5
+ * The ONLY sanctioned path for REAL execution (write / build / install / test / restart / any
6
+ * command that changes state) is a real, visible agent TUI window launched by
7
+ * `--run-windows`. This guard makes that a *checkable invariant*:
8
+ *
9
+ * - `--enable` : arm enforcement (write the marker). Once armed, real-execution kinds are
10
+ * refused from any NON-window context.
11
+ * - `--disable` : disarm.
12
+ * - `--status` : report armed state + currently alive execution windows.
13
+ * - `--assert <kind>` : returns 0 (ok) / 1 (refused). A sanctioned window sets
14
+ * $env:ORCH_WINDOW=1 (its launcher does), so it passes. A non-window
15
+ * context that tries a real-execution kind gets refused with a route hint.
16
+ * - `--verify <file>` : best-effort check whether a changed file was written inside a live
17
+ * sanctioned window. Reliably PASS (in a window), reliably VIOLATION (no live
18
+ * window / not in a window), or INDETERMINATE (live window exists but this is
19
+ * not that context) — never fabricates a verdict it cannot support.
20
+ *
21
+ * A window is counted as ALIVE only if it is registered in `orchestrator-windows.json` AND its
22
+ * pid file still exists AND `process.kill(pid, 0)` (in try/catch) confirms the process is really
23
+ * running. A record whose pid file exists but whose process has already exited is treated as NOT
24
+ * alive and dropped from results — the old "pid file exists == alive" check is gone.
25
+ *
26
+ * Combined with the hard gate on headless `--run`/`--exec` inside orchestrate-sdk.mjs
27
+ * (ORCH_ALLOW_HEADLESS must be `1`, verboten for real work), this is the "once the skill is
28
+ * enabled, real executions must go through the TUI window" enforcement.
29
+ *
30
+ * Usage (from scripts dir): node open-tui-orchestrator-force.mjs --status | --assert <kind> | --verify <file> | --enable | --disable
31
+ */
32
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, lstatSync, renameSync, realpathSync } from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { spawnSync } from "node:child_process";
36
+ import { fileURLToPath } from "node:url";
37
+ import { powershellExe, systemToolExe } from "./core/contracts.mjs";
38
+
39
+ // 锚定 SystemRoot:裸名会先按进程 cwd(任意工作区)解析,防二进制投毒。
40
+ const PS_EXE = powershellExe();
41
+ const TASKLIST_EXE = systemToolExe("tasklist.exe");
42
+
43
+ import { resolveRuntimeContext } from './runtime-context.mjs';
44
+ import { lockedForMaintenance, readMaintenanceLock, MAINTENANCE_MESSAGE } from './core/maintenance-lock.mjs';
45
+ const { workspace: WORKSPACE, temp: TMP, state: STATE_DIR } = resolveRuntimeContext();
46
+ const FLAG = path.join(STATE_DIR, ".orchestrator-enabled");
47
+ const WINDOWS_REG = path.join(STATE_DIR, "orchestrator-windows.json");
48
+
49
+ // 拒绝符号链接/非常规文件,防止伪造/篡改 registry 或 marker(安全网)。
50
+ function isRegularFile(p) { try { return lstatSync(p).isFile(); } catch { return false; } }
51
+ function armed() {
52
+ if (!existsSync(FLAG) || !isRegularFile(FLAG)) return false;
53
+ try { return /^\d+$/.test(readFileSync(FLAG, "utf8").trim()); } catch { return false; }
54
+ }
55
+ function loadJson(f, d) {
56
+ try {
57
+ if (!existsSync(f)) return d;
58
+ if (!isRegularFile(f)) { console.error("[force] refusing non-regular/symlinked registry: " + f); return d; }
59
+ return JSON.parse(readFileSync(f, "utf8"));
60
+ } catch (e) {
61
+ console.error("[force] registry read/parse failed: " + f + " :: " + e.message);
62
+ return d;
63
+ }
64
+ }
65
+
66
+ // Read the PID stored in a window's pid file. Returns 0 if the record has no pidFile, the pidFile
67
+ // is unreadable, or the stored value is not a positive integer.
68
+ function pidOf(w) {
69
+ try {
70
+ if (!w || !w.pidFile) return 0;
71
+ if (!isRegularFile(w.pidFile)) return 0; // 拒绝符号链接 pidFile
72
+ const p = Number(String(readFileSync(w.pidFile, "utf8")).trim());
73
+ return Number.isFinite(p) && p >= 1 && p <= 4194304 ? p : 0; // 合理 PID 范围
74
+ } catch { return 0; }
75
+ }
76
+ // process.kill(pid, 0) inside try/catch: true only if the process is actually alive (ESRCH/throw = dead).
77
+ function pidAlive(pid) {
78
+ if (!pid || pid <= 0) return false;
79
+ if (process.platform === "win32") {
80
+ // 防伪造:仅识别“编排器工作窗”的真实进程,而非任意 powershell/codex 像。
81
+ try {
82
+ const r = spawnSync(TASKLIST_EXE, ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { encoding: "utf8", windowsHide: true });
83
+ const out = String(r.stdout || "");
84
+ if (!/"\S+","\d+"/.test(out)) return false; // 该 PID 无对应行
85
+ const img = String(out.split(/\r?\n/)[0] || "").split(",")[0].replace(/"/g, "");
86
+ const codexish = /codex|powershell/i.test(img);
87
+ if (!codexish) return false;
88
+ // 命令行动态核对:编排器工作窗的 launcher 命令行是 `powershell -File ...\win-launch-*.ps1`
89
+ // (其 agent 子进程命令行不含编排器字样),故需命中 win-launch-*.ps1;同时保留 orchestrate-sdk.mjs / --run-windows 以兼容旧判定。
90
+ // 其它任意 powershell/codex 一律视作伪造,不得过门禁(fail-closed)。
91
+ // pid 已在 pidOf() 校验为 1..4194304 的合法整数,直接内联进 -Filter 安全;用 `$args[0]` 传参会
92
+ // 触发 PowerShell 的 -Command 解析异常(UnexpectedToken),导致 CommandLine 查询返回空,故改为内联。
93
+ const ps = spawnSync(PS_EXE, ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction SilentlyContinue).CommandLine`], { encoding: "utf8", windowsHide: true });
94
+ const cl = String(ps.stdout || "");
95
+ return /win-launch-.*\.ps1/i.test(cl) || /orchestrate-sdk\.mjs/i.test(cl) || /--run-windows/i.test(cl);
96
+ } catch { return false; }
97
+ }
98
+ try { process.kill(pid, 0); return true; } catch (e) { return e && (e.code === "EPERM" || e.code === "EACCES") ? true : false; } // EPERM/EACCES = 存在但无权限, 不视为 dead
99
+ }
100
+ // Only windows whose process is really alive are returned. A record whose pidFile exists but whose
101
+ // process has already exited is treated as NOT alive and dropped from the result.
102
+ function liveWindows() {
103
+ const reg = loadJson(WINDOWS_REG, []);
104
+ if (!Array.isArray(reg)) { console.error("[force] refusing: orchestrator-windows.json is not an array (fail-closed)."); return []; }
105
+ return reg.filter((w) => {
106
+ if (!w.pidFile) return false;
107
+ return pidAlive(pidOf(w));
108
+ });
109
+ }
110
+
111
+ const mode = process.argv[2];
112
+
113
+ function runCli() {
114
+ // 维护锁:上锁期间 --assert/--verify 一律放行并回固定话术(本轮跳过 orchestrator 直接执行);
115
+ // --status 显示锁状态后继续正常输出;--enable/--disable 属维护操作,不受影响。
116
+ if (lockedForMaintenance()) {
117
+ if (mode === "--assert" || mode === "--verify") {
118
+ console.log(MAINTENANCE_MESSAGE);
119
+ process.exit(0);
120
+ }
121
+ if (mode === "--status") {
122
+ const mlock = readMaintenanceLock();
123
+ console.log("maintenance lock: LOCKED" + (mlock.reason ? " (" + mlock.reason + ")" : "") + (mlock.expiresAt ? ", expires " + new Date(mlock.expiresAt).toISOString() : "") + " - " + MAINTENANCE_MESSAGE);
124
+ }
125
+ }
126
+ if (mode === "--enable") {
127
+ mkdirSync(STATE_DIR, { recursive: true });
128
+ // 小标记直接用 writeFileSync 覆盖写,避免 tmp+rename→rm→rename 间隙内标记缺失的竞态。
129
+ writeFileSync(FLAG, String(Date.now()), "utf8");
130
+ console.log("enforcement ARMED: real executions must go through --run-windows (visible agent TUI).");
131
+ } else if (mode === "--disable") {
132
+ try { rmSync(FLAG, { force: true }); } catch { /* ignore */ }
133
+ console.log("enforcement disarmed.");
134
+ } else if (mode === "--status") {
135
+ const wins = liveWindows();
136
+ console.log("enforcement: " + (armed() ? "ARMED" : "disarmed"));
137
+ console.log("alive execution windows: " + wins.length);
138
+ for (const w of wins) console.log(" [" + w.title + "]");
139
+ } else if (mode === "--assert") {
140
+ const kind = String(process.argv[3] || "exec").toLowerCase();
141
+ const realKinds = new Set(["exec", "write", "build", "install", "test", "restart", "start", "stop", "real", "all", "run"]);
142
+ const readKinds = new Set(["read", "analyze", "plan", "view", "query", "search", "status", "list", "preview", "ls", "show", "check", "validate", "dry-run", "help"]);
143
+ if (!armed()) { console.log("ok: orchestrator not armed."); process.exit(0); }
144
+ if (process.env.ORCH_WINDOW === "1") {
145
+ const wins = liveWindows();
146
+ if (wins.length > 0) {
147
+ console.log("ok: sanctioned orchestration window (" + wins.length + " live registered window(s)).");
148
+ process.exit(0);
149
+ }
150
+ console.log(
151
+ "REFUSED: ORCH_WINDOW=1 but no live registered orchestration window (orchestrator-windows.json). " +
152
+ "The env var alone is not accepted — it may be forged. Route through node orchestrate-sdk.mjs --run-windows \"<request>\" (a real, visible agent TUI window)."
153
+ );
154
+ process.exit(1);
155
+ }
156
+ if (realKinds.has(kind)) {
157
+ console.log(
158
+ "REFUSED: real execution ('" + kind + "') from a NON-orchestrator context. " +
159
+ "Route it through node orchestrate-sdk.mjs --run-windows \"<request>\" (a real, visible agent TUI window)."
160
+ );
161
+ process.exit(1);
162
+ }
163
+ // Unknown kind = fail-closed by default (a typo / future real kind must NOT silently pass).
164
+ if (!readKinds.has(kind)) {
165
+ console.log(
166
+ "REFUSED: unknown execution kind ('" + kind + "'); refusing by default (fail-closed). " +
167
+ "'read/analyze/plan/view/query/search/status/list/preview/ls/show/check/validate/dry-run/help' are read-only kinds."
168
+ );
169
+ process.exit(1);
170
+ }
171
+ console.log("ok: read-only kind.");
172
+ } else if (mode === "--verify") {
173
+ const file = process.argv[3];
174
+ if (!file) { console.log("Usage: open-tui-orchestrator-force.mjs --verify <file>"); process.exit(2); }
175
+ if (!armed()) { console.log("verify: orchestrator not armed; in-window verification not applicable."); process.exit(0); }
176
+ if (!existsSync(file)) { console.log("verify: file not found: " + file); process.exit(2); }
177
+ // 归因精化:不再「任一活窗+文件在工作区内=放行」,而是按 conflictKeys 确定归因——
178
+ // 仅 1 个活窗且其 conflictKeys 命中该文件 → PASS;
179
+ // 多个活窗 → INDETERMINATE(无法唯一归因到某个窗);
180
+ // 无活窗 / 无命中 → VIOLATION(不能确认是窗内受权写入)。
181
+ let realFile, ws;
182
+ try {
183
+ realFile = realpathSync(file);
184
+ ws = realpathSync(WORKSPACE);
185
+ } catch (e) {
186
+ // fail-closed:路径解析失败无法确认归属,不得放行。
187
+ console.log("VIOLATION: could not resolve the file/workspace path for in-window attribution (" + (e && e.message ? e.message : "unknown") + ").");
188
+ process.exit(1);
189
+ }
190
+ if (realFile !== ws && !realFile.startsWith(ws + path.sep)) {
191
+ console.log("VIOLATION: file is outside the orchestration workspace (" + WORKSPACE + "); cannot attribute to a sanctioned in-window write.");
192
+ process.exit(1);
193
+ }
194
+ const wins = liveWindows();
195
+ const lower = realFile.toLowerCase().replace(/\\/g, "/");
196
+ const covering = wins.filter((w) => {
197
+ const rawKeys = Array.isArray(w.keys) ? w.keys.map(String) : [];
198
+ const keys = rawKeys.length ? rawKeys : ["__" + (w.title || "block")];
199
+ return keys.some((kk) => {
200
+ let raw = String(kk).trim().replace(/^__\s*/, "");
201
+ if (!raw) return false;
202
+ // 去掉 SDK 归约键的资源前缀(file:/dir:/port:/service:),再做路径感知匹配,不做裸子串匹配。
203
+ raw = raw.replace(/^(file|dir|port|service):/i, "").trim();
204
+ if (!raw) return false;
205
+ const norm = raw.toLowerCase().replace(/\\/g, "/").replace(/\/+$/, "");
206
+ if (!norm) return false;
207
+ // 路径感知:精确 / 目录前缀(带分隔符) / 文件名;裸目录名(无分隔符)按路径段匹配。
208
+ if (norm === lower) return true;
209
+ if (lower.startsWith(norm + "/")) return true;
210
+ if (norm === lower.split("/").pop()) return true;
211
+ if (!norm.includes("/") && lower.includes("/" + norm + "/")) return true;
212
+ return false;
213
+ });
214
+ });
215
+ if (wins.length === 0) {
216
+ console.log("VIOLATION: no live registered orchestration window exists; this change cannot be confirmed as an in-window write. Verify/re-run inside a window (node orchestrate-sdk.mjs --run-windows \"<request>\").");
217
+ process.exit(1);
218
+ }
219
+ if (wins.length > 1) {
220
+ console.log("INDETERMINATE: " + wins.length + " live orchestration window(s) exist; the change cannot be uniquely attributed to one window. Confirm per-window attribution for [" + wins.map((w) => w.title).join(", ") + "].");
221
+ process.exit(2);
222
+ }
223
+ // 恰好一个活窗:按其 conflictKeys 归因。
224
+ if (covering.length === 1) {
225
+ console.log("PASS: change attributed to live sanctioned orchestration window [" + (wins[0].title || "?") + "] (conflictKeys hit).");
226
+ process.exit(0);
227
+ }
228
+ console.log("VIOLATION: the only live orchestration window [" + (wins[0].title || "?") + "] does not cover this file's conflictKeys; cannot attribute to a sanctioned in-window write.");
229
+ process.exit(1);
230
+ } else {
231
+ console.log("Usage: open-tui-orchestrator-force.mjs --enable | --disable | --status | --assert <kind> | --verify <file>");
232
+ }
233
+ }
234
+
235
+ // Only run the CLI when this module is the main entrypoint, so open-tui-orchestrator-preflight.mjs can reuse armed()/liveWindows().
236
+ const isMain = process.argv[1] && path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase();
237
+ if (isMain) runCli();
238
+
239
+ export { armed, liveWindows, pidAlive, pidOf, WINDOWS_REG, TMP, FLAG, STATE_DIR };