cx-viewer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +221 -0
- package/cli.js +735 -0
- package/concepts/ar/BodyDiffJSON.md +32 -0
- package/concepts/ar/BodyFields.md +42 -0
- package/concepts/ar/CacheRebuild.md +36 -0
- package/concepts/ar/GlobalSettings.md +232 -0
- package/concepts/ar/KVCacheContent.md +49 -0
- package/concepts/ar/MainAgent.md +23 -0
- package/concepts/ar/ProxySwitch.md +50 -0
- package/concepts/ar/QRCode.md +25 -0
- package/concepts/ar/ResponseFields.md +50 -0
- package/concepts/ar/SubAgent-Search.md +20 -0
- package/concepts/ar/Teammate.md +26 -0
- package/concepts/ar/Tool-Agent.md +125 -0
- package/concepts/ar/Tool-AskUserQuestion.md +75 -0
- package/concepts/ar/Tool-Bash.md +167 -0
- package/concepts/ar/Tool-Edit.md +46 -0
- package/concepts/ar/Tool-EnterPlanMode.md +128 -0
- package/concepts/ar/Tool-EnterWorktree.md +56 -0
- package/concepts/ar/Tool-ExitPlanMode.md +62 -0
- package/concepts/ar/Tool-Glob.md +39 -0
- package/concepts/ar/Tool-Grep.md +57 -0
- package/concepts/ar/Tool-NotebookEdit.md +33 -0
- package/concepts/ar/Tool-Read.md +56 -0
- package/concepts/ar/Tool-SendMessage.md +41 -0
- package/concepts/ar/Tool-Skill.md +56 -0
- package/concepts/ar/Tool-Task.md +51 -0
- package/concepts/ar/Tool-TaskCreate.md +78 -0
- package/concepts/ar/Tool-TaskGet.md +56 -0
- package/concepts/ar/Tool-TaskList.md +56 -0
- package/concepts/ar/Tool-TaskOutput.md +40 -0
- package/concepts/ar/Tool-TaskStop.md +32 -0
- package/concepts/ar/Tool-TaskUpdate.md +123 -0
- package/concepts/ar/Tool-TeamCreate.md +38 -0
- package/concepts/ar/Tool-TeamDelete.md +30 -0
- package/concepts/ar/Tool-WebFetch.md +55 -0
- package/concepts/ar/Tool-WebSearch.md +58 -0
- package/concepts/ar/Tool-Write.md +39 -0
- package/concepts/ar/Tool-executeCode.md +41 -0
- package/concepts/ar/Tool-getDiagnostics.md +32 -0
- package/concepts/ar/Tools.md +67 -0
- package/concepts/ar/ToolsFirst.md +77 -0
- package/concepts/ar/TranslateContextPollution.md +46 -0
- package/concepts/ar/UltraPlan.md +55 -0
- package/concepts/da/BodyDiffJSON.md +32 -0
- package/concepts/da/BodyFields.md +42 -0
- package/concepts/da/CacheRebuild.md +36 -0
- package/concepts/da/GlobalSettings.md +232 -0
- package/concepts/da/KVCacheContent.md +49 -0
- package/concepts/da/MainAgent.md +23 -0
- package/concepts/da/ProxySwitch.md +50 -0
- package/concepts/da/QRCode.md +25 -0
- package/concepts/da/ResponseFields.md +50 -0
- package/concepts/da/SubAgent-Search.md +20 -0
- package/concepts/da/Teammate.md +26 -0
- package/concepts/da/Tool-Agent.md +125 -0
- package/concepts/da/Tool-AskUserQuestion.md +75 -0
- package/concepts/da/Tool-Bash.md +167 -0
- package/concepts/da/Tool-Edit.md +46 -0
- package/concepts/da/Tool-EnterPlanMode.md +128 -0
- package/concepts/da/Tool-EnterWorktree.md +56 -0
- package/concepts/da/Tool-ExitPlanMode.md +62 -0
- package/concepts/da/Tool-Glob.md +39 -0
- package/concepts/da/Tool-Grep.md +57 -0
- package/concepts/da/Tool-NotebookEdit.md +33 -0
- package/concepts/da/Tool-Read.md +56 -0
- package/concepts/da/Tool-SendMessage.md +41 -0
- package/concepts/da/Tool-Skill.md +56 -0
- package/concepts/da/Tool-Task.md +51 -0
- package/concepts/da/Tool-TaskCreate.md +78 -0
- package/concepts/da/Tool-TaskGet.md +56 -0
- package/concepts/da/Tool-TaskList.md +56 -0
- package/concepts/da/Tool-TaskOutput.md +40 -0
- package/concepts/da/Tool-TaskStop.md +32 -0
- package/concepts/da/Tool-TaskUpdate.md +123 -0
- package/concepts/da/Tool-TeamCreate.md +38 -0
- package/concepts/da/Tool-TeamDelete.md +30 -0
- package/concepts/da/Tool-WebFetch.md +55 -0
- package/concepts/da/Tool-WebSearch.md +58 -0
- package/concepts/da/Tool-Write.md +39 -0
- package/concepts/da/Tool-executeCode.md +41 -0
- package/concepts/da/Tool-getDiagnostics.md +32 -0
- package/concepts/da/Tools.md +67 -0
- package/concepts/da/ToolsFirst.md +77 -0
- package/concepts/da/TranslateContextPollution.md +46 -0
- package/concepts/da/UltraPlan.md +55 -0
- package/concepts/de/BodyDiffJSON.md +32 -0
- package/concepts/de/BodyFields.md +42 -0
- package/concepts/de/CacheRebuild.md +36 -0
- package/concepts/de/GlobalSettings.md +232 -0
- package/concepts/de/KVCacheContent.md +49 -0
- package/concepts/de/MainAgent.md +23 -0
- package/concepts/de/ProxySwitch.md +50 -0
- package/concepts/de/QRCode.md +25 -0
- package/concepts/de/ResponseFields.md +50 -0
- package/concepts/de/SubAgent-Search.md +20 -0
- package/concepts/de/Teammate.md +26 -0
- package/concepts/de/Tool-Agent.md +125 -0
- package/concepts/de/Tool-AskUserQuestion.md +75 -0
- package/concepts/de/Tool-Bash.md +167 -0
- package/concepts/de/Tool-Edit.md +46 -0
- package/concepts/de/Tool-EnterPlanMode.md +128 -0
- package/concepts/de/Tool-EnterWorktree.md +56 -0
- package/concepts/de/Tool-ExitPlanMode.md +62 -0
- package/concepts/de/Tool-Glob.md +39 -0
- package/concepts/de/Tool-Grep.md +57 -0
- package/concepts/de/Tool-NotebookEdit.md +33 -0
- package/concepts/de/Tool-Read.md +56 -0
- package/concepts/de/Tool-SendMessage.md +41 -0
- package/concepts/de/Tool-Skill.md +56 -0
- package/concepts/de/Tool-Task.md +51 -0
- package/concepts/de/Tool-TaskCreate.md +78 -0
- package/concepts/de/Tool-TaskGet.md +56 -0
- package/concepts/de/Tool-TaskList.md +56 -0
- package/concepts/de/Tool-TaskOutput.md +40 -0
- package/concepts/de/Tool-TaskStop.md +32 -0
- package/concepts/de/Tool-TaskUpdate.md +123 -0
- package/concepts/de/Tool-TeamCreate.md +38 -0
- package/concepts/de/Tool-TeamDelete.md +30 -0
- package/concepts/de/Tool-WebFetch.md +55 -0
- package/concepts/de/Tool-WebSearch.md +58 -0
- package/concepts/de/Tool-Write.md +39 -0
- package/concepts/de/Tool-executeCode.md +41 -0
- package/concepts/de/Tool-getDiagnostics.md +32 -0
- package/concepts/de/Tools.md +67 -0
- package/concepts/de/ToolsFirst.md +77 -0
- package/concepts/de/TranslateContextPollution.md +46 -0
- package/concepts/de/UltraPlan.md +55 -0
- package/concepts/en/BodyDiffJSON.md +32 -0
- package/concepts/en/BodyFields.md +42 -0
- package/concepts/en/CacheRebuild.md +36 -0
- package/concepts/en/GlobalSettings.md +232 -0
- package/concepts/en/KVCacheContent.md +49 -0
- package/concepts/en/MainAgent.md +23 -0
- package/concepts/en/ProxySwitch.md +50 -0
- package/concepts/en/QRCode.md +29 -0
- package/concepts/en/ResponseFields.md +50 -0
- package/concepts/en/SubAgent-Search.md +20 -0
- package/concepts/en/Teammate.md +26 -0
- package/concepts/en/Tool-Agent.md +125 -0
- package/concepts/en/Tool-AskUserQuestion.md +75 -0
- package/concepts/en/Tool-Bash.md +167 -0
- package/concepts/en/Tool-Edit.md +46 -0
- package/concepts/en/Tool-EnterPlanMode.md +128 -0
- package/concepts/en/Tool-EnterWorktree.md +56 -0
- package/concepts/en/Tool-ExitPlanMode.md +62 -0
- package/concepts/en/Tool-Glob.md +39 -0
- package/concepts/en/Tool-Grep.md +57 -0
- package/concepts/en/Tool-NotebookEdit.md +33 -0
- package/concepts/en/Tool-Read.md +56 -0
- package/concepts/en/Tool-SendMessage.md +41 -0
- package/concepts/en/Tool-Skill.md +56 -0
- package/concepts/en/Tool-Task.md +51 -0
- package/concepts/en/Tool-TaskCreate.md +78 -0
- package/concepts/en/Tool-TaskGet.md +56 -0
- package/concepts/en/Tool-TaskList.md +56 -0
- package/concepts/en/Tool-TaskOutput.md +40 -0
- package/concepts/en/Tool-TaskStop.md +32 -0
- package/concepts/en/Tool-TaskUpdate.md +123 -0
- package/concepts/en/Tool-TeamCreate.md +38 -0
- package/concepts/en/Tool-TeamDelete.md +30 -0
- package/concepts/en/Tool-WebFetch.md +55 -0
- package/concepts/en/Tool-WebSearch.md +58 -0
- package/concepts/en/Tool-Write.md +39 -0
- package/concepts/en/Tool-executeCode.md +41 -0
- package/concepts/en/Tool-getDiagnostics.md +32 -0
- package/concepts/en/Tools.md +67 -0
- package/concepts/en/ToolsFirst.md +77 -0
- package/concepts/en/TranslateContextPollution.md +46 -0
- package/concepts/en/UltraPlan.md +55 -0
- package/concepts/es/BodyDiffJSON.md +32 -0
- package/concepts/es/BodyFields.md +42 -0
- package/concepts/es/CacheRebuild.md +36 -0
- package/concepts/es/GlobalSettings.md +232 -0
- package/concepts/es/KVCacheContent.md +49 -0
- package/concepts/es/MainAgent.md +23 -0
- package/concepts/es/ProxySwitch.md +50 -0
- package/concepts/es/QRCode.md +25 -0
- package/concepts/es/ResponseFields.md +50 -0
- package/concepts/es/SubAgent-Search.md +20 -0
- package/concepts/es/Teammate.md +26 -0
- package/concepts/es/Tool-Agent.md +125 -0
- package/concepts/es/Tool-AskUserQuestion.md +75 -0
- package/concepts/es/Tool-Bash.md +167 -0
- package/concepts/es/Tool-Edit.md +46 -0
- package/concepts/es/Tool-EnterPlanMode.md +128 -0
- package/concepts/es/Tool-EnterWorktree.md +56 -0
- package/concepts/es/Tool-ExitPlanMode.md +62 -0
- package/concepts/es/Tool-Glob.md +39 -0
- package/concepts/es/Tool-Grep.md +57 -0
- package/concepts/es/Tool-NotebookEdit.md +33 -0
- package/concepts/es/Tool-Read.md +56 -0
- package/concepts/es/Tool-SendMessage.md +41 -0
- package/concepts/es/Tool-Skill.md +56 -0
- package/concepts/es/Tool-Task.md +51 -0
- package/concepts/es/Tool-TaskCreate.md +78 -0
- package/concepts/es/Tool-TaskGet.md +56 -0
- package/concepts/es/Tool-TaskList.md +56 -0
- package/concepts/es/Tool-TaskOutput.md +40 -0
- package/concepts/es/Tool-TaskStop.md +32 -0
- package/concepts/es/Tool-TaskUpdate.md +123 -0
- package/concepts/es/Tool-TeamCreate.md +38 -0
- package/concepts/es/Tool-TeamDelete.md +30 -0
- package/concepts/es/Tool-WebFetch.md +55 -0
- package/concepts/es/Tool-WebSearch.md +58 -0
- package/concepts/es/Tool-Write.md +39 -0
- package/concepts/es/Tool-executeCode.md +41 -0
- package/concepts/es/Tool-getDiagnostics.md +32 -0
- package/concepts/es/Tools.md +67 -0
- package/concepts/es/ToolsFirst.md +77 -0
- package/concepts/es/TranslateContextPollution.md +46 -0
- package/concepts/es/UltraPlan.md +55 -0
- package/concepts/fr/BodyDiffJSON.md +32 -0
- package/concepts/fr/BodyFields.md +42 -0
- package/concepts/fr/CacheRebuild.md +36 -0
- package/concepts/fr/GlobalSettings.md +232 -0
- package/concepts/fr/KVCacheContent.md +49 -0
- package/concepts/fr/MainAgent.md +23 -0
- package/concepts/fr/ProxySwitch.md +50 -0
- package/concepts/fr/QRCode.md +25 -0
- package/concepts/fr/ResponseFields.md +50 -0
- package/concepts/fr/SubAgent-Search.md +20 -0
- package/concepts/fr/Teammate.md +26 -0
- package/concepts/fr/Tool-Agent.md +125 -0
- package/concepts/fr/Tool-AskUserQuestion.md +75 -0
- package/concepts/fr/Tool-Bash.md +167 -0
- package/concepts/fr/Tool-Edit.md +46 -0
- package/concepts/fr/Tool-EnterPlanMode.md +128 -0
- package/concepts/fr/Tool-EnterWorktree.md +56 -0
- package/concepts/fr/Tool-ExitPlanMode.md +62 -0
- package/concepts/fr/Tool-Glob.md +39 -0
- package/concepts/fr/Tool-Grep.md +57 -0
- package/concepts/fr/Tool-NotebookEdit.md +33 -0
- package/concepts/fr/Tool-Read.md +56 -0
- package/concepts/fr/Tool-SendMessage.md +41 -0
- package/concepts/fr/Tool-Skill.md +56 -0
- package/concepts/fr/Tool-Task.md +51 -0
- package/concepts/fr/Tool-TaskCreate.md +78 -0
- package/concepts/fr/Tool-TaskGet.md +56 -0
- package/concepts/fr/Tool-TaskList.md +56 -0
- package/concepts/fr/Tool-TaskOutput.md +40 -0
- package/concepts/fr/Tool-TaskStop.md +32 -0
- package/concepts/fr/Tool-TaskUpdate.md +123 -0
- package/concepts/fr/Tool-TeamCreate.md +38 -0
- package/concepts/fr/Tool-TeamDelete.md +30 -0
- package/concepts/fr/Tool-WebFetch.md +55 -0
- package/concepts/fr/Tool-WebSearch.md +58 -0
- package/concepts/fr/Tool-Write.md +39 -0
- package/concepts/fr/Tool-executeCode.md +41 -0
- package/concepts/fr/Tool-getDiagnostics.md +32 -0
- package/concepts/fr/Tools.md +67 -0
- package/concepts/fr/ToolsFirst.md +77 -0
- package/concepts/fr/TranslateContextPollution.md +46 -0
- package/concepts/fr/UltraPlan.md +55 -0
- package/concepts/it/BodyDiffJSON.md +32 -0
- package/concepts/it/BodyFields.md +42 -0
- package/concepts/it/CacheRebuild.md +36 -0
- package/concepts/it/GlobalSettings.md +232 -0
- package/concepts/it/KVCacheContent.md +49 -0
- package/concepts/it/MainAgent.md +23 -0
- package/concepts/it/ProxySwitch.md +50 -0
- package/concepts/it/QRCode.md +25 -0
- package/concepts/it/ResponseFields.md +50 -0
- package/concepts/it/SubAgent-Search.md +20 -0
- package/concepts/it/Teammate.md +26 -0
- package/concepts/it/Tool-Agent.md +125 -0
- package/concepts/it/Tool-AskUserQuestion.md +75 -0
- package/concepts/it/Tool-Bash.md +167 -0
- package/concepts/it/Tool-Edit.md +46 -0
- package/concepts/it/Tool-EnterPlanMode.md +128 -0
- package/concepts/it/Tool-EnterWorktree.md +56 -0
- package/concepts/it/Tool-ExitPlanMode.md +62 -0
- package/concepts/it/Tool-Glob.md +39 -0
- package/concepts/it/Tool-Grep.md +57 -0
- package/concepts/it/Tool-NotebookEdit.md +33 -0
- package/concepts/it/Tool-Read.md +56 -0
- package/concepts/it/Tool-SendMessage.md +41 -0
- package/concepts/it/Tool-Skill.md +56 -0
- package/concepts/it/Tool-Task.md +51 -0
- package/concepts/it/Tool-TaskCreate.md +78 -0
- package/concepts/it/Tool-TaskGet.md +56 -0
- package/concepts/it/Tool-TaskList.md +56 -0
- package/concepts/it/Tool-TaskOutput.md +40 -0
- package/concepts/it/Tool-TaskStop.md +32 -0
- package/concepts/it/Tool-TaskUpdate.md +123 -0
- package/concepts/it/Tool-TeamCreate.md +38 -0
- package/concepts/it/Tool-TeamDelete.md +30 -0
- package/concepts/it/Tool-WebFetch.md +55 -0
- package/concepts/it/Tool-WebSearch.md +58 -0
- package/concepts/it/Tool-Write.md +39 -0
- package/concepts/it/Tool-executeCode.md +41 -0
- package/concepts/it/Tool-getDiagnostics.md +32 -0
- package/concepts/it/Tools.md +67 -0
- package/concepts/it/ToolsFirst.md +77 -0
- package/concepts/it/TranslateContextPollution.md +46 -0
- package/concepts/it/UltraPlan.md +55 -0
- package/concepts/ja/BodyDiffJSON.md +32 -0
- package/concepts/ja/BodyFields.md +42 -0
- package/concepts/ja/CacheRebuild.md +36 -0
- package/concepts/ja/GlobalSettings.md +232 -0
- package/concepts/ja/KVCacheContent.md +49 -0
- package/concepts/ja/MainAgent.md +23 -0
- package/concepts/ja/ProxySwitch.md +50 -0
- package/concepts/ja/QRCode.md +25 -0
- package/concepts/ja/ResponseFields.md +50 -0
- package/concepts/ja/SubAgent-Search.md +20 -0
- package/concepts/ja/Teammate.md +26 -0
- package/concepts/ja/Tool-Agent.md +125 -0
- package/concepts/ja/Tool-AskUserQuestion.md +75 -0
- package/concepts/ja/Tool-Bash.md +167 -0
- package/concepts/ja/Tool-Edit.md +46 -0
- package/concepts/ja/Tool-EnterPlanMode.md +128 -0
- package/concepts/ja/Tool-EnterWorktree.md +56 -0
- package/concepts/ja/Tool-ExitPlanMode.md +62 -0
- package/concepts/ja/Tool-Glob.md +39 -0
- package/concepts/ja/Tool-Grep.md +57 -0
- package/concepts/ja/Tool-NotebookEdit.md +33 -0
- package/concepts/ja/Tool-Read.md +56 -0
- package/concepts/ja/Tool-SendMessage.md +41 -0
- package/concepts/ja/Tool-Skill.md +56 -0
- package/concepts/ja/Tool-Task.md +51 -0
- package/concepts/ja/Tool-TaskCreate.md +78 -0
- package/concepts/ja/Tool-TaskGet.md +56 -0
- package/concepts/ja/Tool-TaskList.md +56 -0
- package/concepts/ja/Tool-TaskOutput.md +40 -0
- package/concepts/ja/Tool-TaskStop.md +32 -0
- package/concepts/ja/Tool-TaskUpdate.md +123 -0
- package/concepts/ja/Tool-TeamCreate.md +38 -0
- package/concepts/ja/Tool-TeamDelete.md +30 -0
- package/concepts/ja/Tool-WebFetch.md +55 -0
- package/concepts/ja/Tool-WebSearch.md +58 -0
- package/concepts/ja/Tool-Write.md +39 -0
- package/concepts/ja/Tool-executeCode.md +41 -0
- package/concepts/ja/Tool-getDiagnostics.md +32 -0
- package/concepts/ja/Tools.md +67 -0
- package/concepts/ja/ToolsFirst.md +77 -0
- package/concepts/ja/TranslateContextPollution.md +46 -0
- package/concepts/ja/UltraPlan.md +55 -0
- package/concepts/ko/BodyDiffJSON.md +32 -0
- package/concepts/ko/BodyFields.md +42 -0
- package/concepts/ko/CacheRebuild.md +36 -0
- package/concepts/ko/GlobalSettings.md +232 -0
- package/concepts/ko/KVCacheContent.md +49 -0
- package/concepts/ko/MainAgent.md +23 -0
- package/concepts/ko/ProxySwitch.md +50 -0
- package/concepts/ko/QRCode.md +25 -0
- package/concepts/ko/ResponseFields.md +50 -0
- package/concepts/ko/SubAgent-Search.md +20 -0
- package/concepts/ko/Teammate.md +26 -0
- package/concepts/ko/Tool-Agent.md +125 -0
- package/concepts/ko/Tool-AskUserQuestion.md +75 -0
- package/concepts/ko/Tool-Bash.md +167 -0
- package/concepts/ko/Tool-Edit.md +46 -0
- package/concepts/ko/Tool-EnterPlanMode.md +128 -0
- package/concepts/ko/Tool-EnterWorktree.md +56 -0
- package/concepts/ko/Tool-ExitPlanMode.md +62 -0
- package/concepts/ko/Tool-Glob.md +39 -0
- package/concepts/ko/Tool-Grep.md +57 -0
- package/concepts/ko/Tool-NotebookEdit.md +33 -0
- package/concepts/ko/Tool-Read.md +56 -0
- package/concepts/ko/Tool-SendMessage.md +41 -0
- package/concepts/ko/Tool-Skill.md +56 -0
- package/concepts/ko/Tool-Task.md +51 -0
- package/concepts/ko/Tool-TaskCreate.md +78 -0
- package/concepts/ko/Tool-TaskGet.md +56 -0
- package/concepts/ko/Tool-TaskList.md +56 -0
- package/concepts/ko/Tool-TaskOutput.md +40 -0
- package/concepts/ko/Tool-TaskStop.md +32 -0
- package/concepts/ko/Tool-TaskUpdate.md +123 -0
- package/concepts/ko/Tool-TeamCreate.md +38 -0
- package/concepts/ko/Tool-TeamDelete.md +30 -0
- package/concepts/ko/Tool-WebFetch.md +55 -0
- package/concepts/ko/Tool-WebSearch.md +58 -0
- package/concepts/ko/Tool-Write.md +39 -0
- package/concepts/ko/Tool-executeCode.md +41 -0
- package/concepts/ko/Tool-getDiagnostics.md +32 -0
- package/concepts/ko/Tools.md +67 -0
- package/concepts/ko/ToolsFirst.md +77 -0
- package/concepts/ko/TranslateContextPollution.md +46 -0
- package/concepts/ko/UltraPlan.md +55 -0
- package/concepts/no/BodyDiffJSON.md +32 -0
- package/concepts/no/BodyFields.md +42 -0
- package/concepts/no/CacheRebuild.md +36 -0
- package/concepts/no/GlobalSettings.md +232 -0
- package/concepts/no/KVCacheContent.md +49 -0
- package/concepts/no/MainAgent.md +23 -0
- package/concepts/no/ProxySwitch.md +50 -0
- package/concepts/no/QRCode.md +25 -0
- package/concepts/no/ResponseFields.md +50 -0
- package/concepts/no/SubAgent-Search.md +20 -0
- package/concepts/no/Teammate.md +26 -0
- package/concepts/no/Tool-Agent.md +125 -0
- package/concepts/no/Tool-AskUserQuestion.md +75 -0
- package/concepts/no/Tool-Bash.md +167 -0
- package/concepts/no/Tool-Edit.md +46 -0
- package/concepts/no/Tool-EnterPlanMode.md +128 -0
- package/concepts/no/Tool-EnterWorktree.md +56 -0
- package/concepts/no/Tool-ExitPlanMode.md +62 -0
- package/concepts/no/Tool-Glob.md +39 -0
- package/concepts/no/Tool-Grep.md +57 -0
- package/concepts/no/Tool-NotebookEdit.md +33 -0
- package/concepts/no/Tool-Read.md +56 -0
- package/concepts/no/Tool-SendMessage.md +41 -0
- package/concepts/no/Tool-Skill.md +56 -0
- package/concepts/no/Tool-Task.md +51 -0
- package/concepts/no/Tool-TaskCreate.md +78 -0
- package/concepts/no/Tool-TaskGet.md +56 -0
- package/concepts/no/Tool-TaskList.md +56 -0
- package/concepts/no/Tool-TaskOutput.md +40 -0
- package/concepts/no/Tool-TaskStop.md +32 -0
- package/concepts/no/Tool-TaskUpdate.md +123 -0
- package/concepts/no/Tool-TeamCreate.md +38 -0
- package/concepts/no/Tool-TeamDelete.md +30 -0
- package/concepts/no/Tool-WebFetch.md +55 -0
- package/concepts/no/Tool-WebSearch.md +58 -0
- package/concepts/no/Tool-Write.md +39 -0
- package/concepts/no/Tool-executeCode.md +41 -0
- package/concepts/no/Tool-getDiagnostics.md +32 -0
- package/concepts/no/Tools.md +67 -0
- package/concepts/no/ToolsFirst.md +77 -0
- package/concepts/no/TranslateContextPollution.md +46 -0
- package/concepts/no/UltraPlan.md +55 -0
- package/concepts/pl/BodyDiffJSON.md +32 -0
- package/concepts/pl/BodyFields.md +42 -0
- package/concepts/pl/CacheRebuild.md +36 -0
- package/concepts/pl/GlobalSettings.md +232 -0
- package/concepts/pl/KVCacheContent.md +49 -0
- package/concepts/pl/MainAgent.md +23 -0
- package/concepts/pl/ProxySwitch.md +50 -0
- package/concepts/pl/QRCode.md +25 -0
- package/concepts/pl/ResponseFields.md +50 -0
- package/concepts/pl/SubAgent-Search.md +20 -0
- package/concepts/pl/Teammate.md +26 -0
- package/concepts/pl/Tool-Agent.md +125 -0
- package/concepts/pl/Tool-AskUserQuestion.md +75 -0
- package/concepts/pl/Tool-Bash.md +167 -0
- package/concepts/pl/Tool-Edit.md +46 -0
- package/concepts/pl/Tool-EnterPlanMode.md +128 -0
- package/concepts/pl/Tool-EnterWorktree.md +56 -0
- package/concepts/pl/Tool-ExitPlanMode.md +62 -0
- package/concepts/pl/Tool-Glob.md +39 -0
- package/concepts/pl/Tool-Grep.md +57 -0
- package/concepts/pl/Tool-NotebookEdit.md +33 -0
- package/concepts/pl/Tool-Read.md +56 -0
- package/concepts/pl/Tool-SendMessage.md +41 -0
- package/concepts/pl/Tool-Skill.md +56 -0
- package/concepts/pl/Tool-Task.md +51 -0
- package/concepts/pl/Tool-TaskCreate.md +78 -0
- package/concepts/pl/Tool-TaskGet.md +56 -0
- package/concepts/pl/Tool-TaskList.md +56 -0
- package/concepts/pl/Tool-TaskOutput.md +40 -0
- package/concepts/pl/Tool-TaskStop.md +32 -0
- package/concepts/pl/Tool-TaskUpdate.md +123 -0
- package/concepts/pl/Tool-TeamCreate.md +38 -0
- package/concepts/pl/Tool-TeamDelete.md +30 -0
- package/concepts/pl/Tool-WebFetch.md +55 -0
- package/concepts/pl/Tool-WebSearch.md +58 -0
- package/concepts/pl/Tool-Write.md +39 -0
- package/concepts/pl/Tool-executeCode.md +41 -0
- package/concepts/pl/Tool-getDiagnostics.md +32 -0
- package/concepts/pl/Tools.md +67 -0
- package/concepts/pl/ToolsFirst.md +77 -0
- package/concepts/pl/TranslateContextPollution.md +46 -0
- package/concepts/pl/UltraPlan.md +55 -0
- package/concepts/pt-BR/BodyDiffJSON.md +32 -0
- package/concepts/pt-BR/BodyFields.md +42 -0
- package/concepts/pt-BR/CacheRebuild.md +36 -0
- package/concepts/pt-BR/GlobalSettings.md +232 -0
- package/concepts/pt-BR/KVCacheContent.md +49 -0
- package/concepts/pt-BR/MainAgent.md +23 -0
- package/concepts/pt-BR/ProxySwitch.md +50 -0
- package/concepts/pt-BR/QRCode.md +25 -0
- package/concepts/pt-BR/ResponseFields.md +50 -0
- package/concepts/pt-BR/SubAgent-Search.md +20 -0
- package/concepts/pt-BR/Teammate.md +26 -0
- package/concepts/pt-BR/Tool-Agent.md +125 -0
- package/concepts/pt-BR/Tool-AskUserQuestion.md +75 -0
- package/concepts/pt-BR/Tool-Bash.md +167 -0
- package/concepts/pt-BR/Tool-Edit.md +46 -0
- package/concepts/pt-BR/Tool-EnterPlanMode.md +128 -0
- package/concepts/pt-BR/Tool-EnterWorktree.md +56 -0
- package/concepts/pt-BR/Tool-ExitPlanMode.md +62 -0
- package/concepts/pt-BR/Tool-Glob.md +39 -0
- package/concepts/pt-BR/Tool-Grep.md +57 -0
- package/concepts/pt-BR/Tool-NotebookEdit.md +33 -0
- package/concepts/pt-BR/Tool-Read.md +56 -0
- package/concepts/pt-BR/Tool-SendMessage.md +41 -0
- package/concepts/pt-BR/Tool-Skill.md +56 -0
- package/concepts/pt-BR/Tool-Task.md +51 -0
- package/concepts/pt-BR/Tool-TaskCreate.md +78 -0
- package/concepts/pt-BR/Tool-TaskGet.md +56 -0
- package/concepts/pt-BR/Tool-TaskList.md +56 -0
- package/concepts/pt-BR/Tool-TaskOutput.md +40 -0
- package/concepts/pt-BR/Tool-TaskStop.md +32 -0
- package/concepts/pt-BR/Tool-TaskUpdate.md +123 -0
- package/concepts/pt-BR/Tool-TeamCreate.md +38 -0
- package/concepts/pt-BR/Tool-TeamDelete.md +30 -0
- package/concepts/pt-BR/Tool-WebFetch.md +55 -0
- package/concepts/pt-BR/Tool-WebSearch.md +58 -0
- package/concepts/pt-BR/Tool-Write.md +39 -0
- package/concepts/pt-BR/Tool-executeCode.md +41 -0
- package/concepts/pt-BR/Tool-getDiagnostics.md +32 -0
- package/concepts/pt-BR/Tools.md +67 -0
- package/concepts/pt-BR/ToolsFirst.md +77 -0
- package/concepts/pt-BR/TranslateContextPollution.md +46 -0
- package/concepts/pt-BR/UltraPlan.md +55 -0
- package/concepts/ru/BodyDiffJSON.md +32 -0
- package/concepts/ru/BodyFields.md +42 -0
- package/concepts/ru/CacheRebuild.md +36 -0
- package/concepts/ru/GlobalSettings.md +232 -0
- package/concepts/ru/KVCacheContent.md +49 -0
- package/concepts/ru/MainAgent.md +23 -0
- package/concepts/ru/ProxySwitch.md +50 -0
- package/concepts/ru/QRCode.md +25 -0
- package/concepts/ru/ResponseFields.md +50 -0
- package/concepts/ru/SubAgent-Search.md +20 -0
- package/concepts/ru/Teammate.md +26 -0
- package/concepts/ru/Tool-Agent.md +125 -0
- package/concepts/ru/Tool-AskUserQuestion.md +75 -0
- package/concepts/ru/Tool-Bash.md +167 -0
- package/concepts/ru/Tool-Edit.md +46 -0
- package/concepts/ru/Tool-EnterPlanMode.md +128 -0
- package/concepts/ru/Tool-EnterWorktree.md +56 -0
- package/concepts/ru/Tool-ExitPlanMode.md +62 -0
- package/concepts/ru/Tool-Glob.md +39 -0
- package/concepts/ru/Tool-Grep.md +57 -0
- package/concepts/ru/Tool-NotebookEdit.md +33 -0
- package/concepts/ru/Tool-Read.md +56 -0
- package/concepts/ru/Tool-SendMessage.md +41 -0
- package/concepts/ru/Tool-Skill.md +56 -0
- package/concepts/ru/Tool-Task.md +51 -0
- package/concepts/ru/Tool-TaskCreate.md +78 -0
- package/concepts/ru/Tool-TaskGet.md +56 -0
- package/concepts/ru/Tool-TaskList.md +56 -0
- package/concepts/ru/Tool-TaskOutput.md +40 -0
- package/concepts/ru/Tool-TaskStop.md +32 -0
- package/concepts/ru/Tool-TaskUpdate.md +123 -0
- package/concepts/ru/Tool-TeamCreate.md +38 -0
- package/concepts/ru/Tool-TeamDelete.md +30 -0
- package/concepts/ru/Tool-WebFetch.md +55 -0
- package/concepts/ru/Tool-WebSearch.md +58 -0
- package/concepts/ru/Tool-Write.md +39 -0
- package/concepts/ru/Tool-executeCode.md +41 -0
- package/concepts/ru/Tool-getDiagnostics.md +32 -0
- package/concepts/ru/Tools.md +67 -0
- package/concepts/ru/ToolsFirst.md +77 -0
- package/concepts/ru/TranslateContextPollution.md +46 -0
- package/concepts/ru/UltraPlan.md +55 -0
- package/concepts/th/BodyDiffJSON.md +32 -0
- package/concepts/th/BodyFields.md +42 -0
- package/concepts/th/CacheRebuild.md +36 -0
- package/concepts/th/GlobalSettings.md +232 -0
- package/concepts/th/KVCacheContent.md +49 -0
- package/concepts/th/MainAgent.md +23 -0
- package/concepts/th/ProxySwitch.md +50 -0
- package/concepts/th/QRCode.md +25 -0
- package/concepts/th/ResponseFields.md +50 -0
- package/concepts/th/SubAgent-Search.md +20 -0
- package/concepts/th/Teammate.md +26 -0
- package/concepts/th/Tool-Agent.md +125 -0
- package/concepts/th/Tool-AskUserQuestion.md +75 -0
- package/concepts/th/Tool-Bash.md +167 -0
- package/concepts/th/Tool-Edit.md +46 -0
- package/concepts/th/Tool-EnterPlanMode.md +128 -0
- package/concepts/th/Tool-EnterWorktree.md +56 -0
- package/concepts/th/Tool-ExitPlanMode.md +62 -0
- package/concepts/th/Tool-Glob.md +39 -0
- package/concepts/th/Tool-Grep.md +57 -0
- package/concepts/th/Tool-NotebookEdit.md +33 -0
- package/concepts/th/Tool-Read.md +56 -0
- package/concepts/th/Tool-SendMessage.md +41 -0
- package/concepts/th/Tool-Skill.md +56 -0
- package/concepts/th/Tool-Task.md +51 -0
- package/concepts/th/Tool-TaskCreate.md +78 -0
- package/concepts/th/Tool-TaskGet.md +56 -0
- package/concepts/th/Tool-TaskList.md +56 -0
- package/concepts/th/Tool-TaskOutput.md +40 -0
- package/concepts/th/Tool-TaskStop.md +32 -0
- package/concepts/th/Tool-TaskUpdate.md +123 -0
- package/concepts/th/Tool-TeamCreate.md +38 -0
- package/concepts/th/Tool-TeamDelete.md +30 -0
- package/concepts/th/Tool-WebFetch.md +55 -0
- package/concepts/th/Tool-WebSearch.md +58 -0
- package/concepts/th/Tool-Write.md +39 -0
- package/concepts/th/Tool-executeCode.md +41 -0
- package/concepts/th/Tool-getDiagnostics.md +32 -0
- package/concepts/th/Tools.md +67 -0
- package/concepts/th/ToolsFirst.md +77 -0
- package/concepts/th/TranslateContextPollution.md +46 -0
- package/concepts/th/UltraPlan.md +55 -0
- package/concepts/tr/BodyDiffJSON.md +32 -0
- package/concepts/tr/BodyFields.md +42 -0
- package/concepts/tr/CacheRebuild.md +36 -0
- package/concepts/tr/GlobalSettings.md +232 -0
- package/concepts/tr/KVCacheContent.md +49 -0
- package/concepts/tr/MainAgent.md +23 -0
- package/concepts/tr/ProxySwitch.md +50 -0
- package/concepts/tr/QRCode.md +25 -0
- package/concepts/tr/ResponseFields.md +50 -0
- package/concepts/tr/SubAgent-Search.md +20 -0
- package/concepts/tr/Teammate.md +26 -0
- package/concepts/tr/Tool-Agent.md +125 -0
- package/concepts/tr/Tool-AskUserQuestion.md +75 -0
- package/concepts/tr/Tool-Bash.md +167 -0
- package/concepts/tr/Tool-Edit.md +46 -0
- package/concepts/tr/Tool-EnterPlanMode.md +128 -0
- package/concepts/tr/Tool-EnterWorktree.md +56 -0
- package/concepts/tr/Tool-ExitPlanMode.md +62 -0
- package/concepts/tr/Tool-Glob.md +39 -0
- package/concepts/tr/Tool-Grep.md +57 -0
- package/concepts/tr/Tool-NotebookEdit.md +33 -0
- package/concepts/tr/Tool-Read.md +56 -0
- package/concepts/tr/Tool-SendMessage.md +41 -0
- package/concepts/tr/Tool-Skill.md +56 -0
- package/concepts/tr/Tool-Task.md +51 -0
- package/concepts/tr/Tool-TaskCreate.md +78 -0
- package/concepts/tr/Tool-TaskGet.md +56 -0
- package/concepts/tr/Tool-TaskList.md +56 -0
- package/concepts/tr/Tool-TaskOutput.md +40 -0
- package/concepts/tr/Tool-TaskStop.md +32 -0
- package/concepts/tr/Tool-TaskUpdate.md +123 -0
- package/concepts/tr/Tool-TeamCreate.md +38 -0
- package/concepts/tr/Tool-TeamDelete.md +30 -0
- package/concepts/tr/Tool-WebFetch.md +55 -0
- package/concepts/tr/Tool-WebSearch.md +58 -0
- package/concepts/tr/Tool-Write.md +39 -0
- package/concepts/tr/Tool-executeCode.md +41 -0
- package/concepts/tr/Tool-getDiagnostics.md +32 -0
- package/concepts/tr/Tools.md +67 -0
- package/concepts/tr/ToolsFirst.md +77 -0
- package/concepts/tr/TranslateContextPollution.md +46 -0
- package/concepts/tr/UltraPlan.md +55 -0
- package/concepts/uk/BodyDiffJSON.md +32 -0
- package/concepts/uk/BodyFields.md +42 -0
- package/concepts/uk/CacheRebuild.md +36 -0
- package/concepts/uk/GlobalSettings.md +232 -0
- package/concepts/uk/KVCacheContent.md +49 -0
- package/concepts/uk/MainAgent.md +23 -0
- package/concepts/uk/ProxySwitch.md +50 -0
- package/concepts/uk/QRCode.md +25 -0
- package/concepts/uk/ResponseFields.md +50 -0
- package/concepts/uk/SubAgent-Search.md +20 -0
- package/concepts/uk/Teammate.md +26 -0
- package/concepts/uk/Tool-Agent.md +125 -0
- package/concepts/uk/Tool-AskUserQuestion.md +75 -0
- package/concepts/uk/Tool-Bash.md +167 -0
- package/concepts/uk/Tool-Edit.md +46 -0
- package/concepts/uk/Tool-EnterPlanMode.md +128 -0
- package/concepts/uk/Tool-EnterWorktree.md +56 -0
- package/concepts/uk/Tool-ExitPlanMode.md +62 -0
- package/concepts/uk/Tool-Glob.md +39 -0
- package/concepts/uk/Tool-Grep.md +57 -0
- package/concepts/uk/Tool-NotebookEdit.md +33 -0
- package/concepts/uk/Tool-Read.md +56 -0
- package/concepts/uk/Tool-SendMessage.md +41 -0
- package/concepts/uk/Tool-Skill.md +56 -0
- package/concepts/uk/Tool-Task.md +51 -0
- package/concepts/uk/Tool-TaskCreate.md +78 -0
- package/concepts/uk/Tool-TaskGet.md +56 -0
- package/concepts/uk/Tool-TaskList.md +56 -0
- package/concepts/uk/Tool-TaskOutput.md +40 -0
- package/concepts/uk/Tool-TaskStop.md +32 -0
- package/concepts/uk/Tool-TaskUpdate.md +123 -0
- package/concepts/uk/Tool-TeamCreate.md +38 -0
- package/concepts/uk/Tool-TeamDelete.md +30 -0
- package/concepts/uk/Tool-WebFetch.md +55 -0
- package/concepts/uk/Tool-WebSearch.md +58 -0
- package/concepts/uk/Tool-Write.md +39 -0
- package/concepts/uk/Tool-executeCode.md +41 -0
- package/concepts/uk/Tool-getDiagnostics.md +32 -0
- package/concepts/uk/Tools.md +67 -0
- package/concepts/uk/ToolsFirst.md +77 -0
- package/concepts/uk/TranslateContextPollution.md +46 -0
- package/concepts/uk/UltraPlan.md +55 -0
- package/concepts/zh/BodyDiffJSON.md +32 -0
- package/concepts/zh/BodyFields.md +42 -0
- package/concepts/zh/CacheRebuild.md +36 -0
- package/concepts/zh/GlobalSettings.md +232 -0
- package/concepts/zh/KVCacheContent.md +49 -0
- package/concepts/zh/MainAgent.md +23 -0
- package/concepts/zh/ProxySwitch.md +50 -0
- package/concepts/zh/QRCode.md +29 -0
- package/concepts/zh/ResponseFields.md +50 -0
- package/concepts/zh/SubAgent-Search.md +20 -0
- package/concepts/zh/Teammate.md +26 -0
- package/concepts/zh/Tool-Agent.md +125 -0
- package/concepts/zh/Tool-AskUserQuestion.md +75 -0
- package/concepts/zh/Tool-Bash.md +167 -0
- package/concepts/zh/Tool-Edit.md +46 -0
- package/concepts/zh/Tool-EnterPlanMode.md +128 -0
- package/concepts/zh/Tool-EnterWorktree.md +56 -0
- package/concepts/zh/Tool-ExitPlanMode.md +62 -0
- package/concepts/zh/Tool-Glob.md +39 -0
- package/concepts/zh/Tool-Grep.md +57 -0
- package/concepts/zh/Tool-NotebookEdit.md +33 -0
- package/concepts/zh/Tool-Read.md +56 -0
- package/concepts/zh/Tool-SendMessage.md +41 -0
- package/concepts/zh/Tool-Skill.md +56 -0
- package/concepts/zh/Tool-Task.md +51 -0
- package/concepts/zh/Tool-TaskCreate.md +78 -0
- package/concepts/zh/Tool-TaskGet.md +56 -0
- package/concepts/zh/Tool-TaskList.md +56 -0
- package/concepts/zh/Tool-TaskOutput.md +40 -0
- package/concepts/zh/Tool-TaskStop.md +32 -0
- package/concepts/zh/Tool-TaskUpdate.md +123 -0
- package/concepts/zh/Tool-TeamCreate.md +38 -0
- package/concepts/zh/Tool-TeamDelete.md +30 -0
- package/concepts/zh/Tool-WebFetch.md +55 -0
- package/concepts/zh/Tool-WebSearch.md +58 -0
- package/concepts/zh/Tool-Write.md +39 -0
- package/concepts/zh/Tool-executeCode.md +41 -0
- package/concepts/zh/Tool-getDiagnostics.md +32 -0
- package/concepts/zh/Tools.md +67 -0
- package/concepts/zh/ToolsFirst.md +77 -0
- package/concepts/zh/TranslateContextPollution.md +46 -0
- package/concepts/zh/UltraPlan.md +55 -0
- package/concepts/zh-TW/BodyDiffJSON.md +32 -0
- package/concepts/zh-TW/BodyFields.md +42 -0
- package/concepts/zh-TW/CacheRebuild.md +36 -0
- package/concepts/zh-TW/GlobalSettings.md +232 -0
- package/concepts/zh-TW/KVCacheContent.md +49 -0
- package/concepts/zh-TW/MainAgent.md +23 -0
- package/concepts/zh-TW/ProxySwitch.md +50 -0
- package/concepts/zh-TW/QRCode.md +25 -0
- package/concepts/zh-TW/ResponseFields.md +50 -0
- package/concepts/zh-TW/SubAgent-Search.md +20 -0
- package/concepts/zh-TW/Teammate.md +26 -0
- package/concepts/zh-TW/Tool-Agent.md +125 -0
- package/concepts/zh-TW/Tool-AskUserQuestion.md +75 -0
- package/concepts/zh-TW/Tool-Bash.md +167 -0
- package/concepts/zh-TW/Tool-Edit.md +46 -0
- package/concepts/zh-TW/Tool-EnterPlanMode.md +128 -0
- package/concepts/zh-TW/Tool-EnterWorktree.md +56 -0
- package/concepts/zh-TW/Tool-ExitPlanMode.md +62 -0
- package/concepts/zh-TW/Tool-Glob.md +39 -0
- package/concepts/zh-TW/Tool-Grep.md +57 -0
- package/concepts/zh-TW/Tool-NotebookEdit.md +33 -0
- package/concepts/zh-TW/Tool-Read.md +56 -0
- package/concepts/zh-TW/Tool-SendMessage.md +41 -0
- package/concepts/zh-TW/Tool-Skill.md +56 -0
- package/concepts/zh-TW/Tool-Task.md +51 -0
- package/concepts/zh-TW/Tool-TaskCreate.md +78 -0
- package/concepts/zh-TW/Tool-TaskGet.md +56 -0
- package/concepts/zh-TW/Tool-TaskList.md +56 -0
- package/concepts/zh-TW/Tool-TaskOutput.md +40 -0
- package/concepts/zh-TW/Tool-TaskStop.md +32 -0
- package/concepts/zh-TW/Tool-TaskUpdate.md +123 -0
- package/concepts/zh-TW/Tool-TeamCreate.md +38 -0
- package/concepts/zh-TW/Tool-TeamDelete.md +30 -0
- package/concepts/zh-TW/Tool-WebFetch.md +55 -0
- package/concepts/zh-TW/Tool-WebSearch.md +58 -0
- package/concepts/zh-TW/Tool-Write.md +39 -0
- package/concepts/zh-TW/Tool-executeCode.md +41 -0
- package/concepts/zh-TW/Tool-getDiagnostics.md +32 -0
- package/concepts/zh-TW/Tools.md +67 -0
- package/concepts/zh-TW/ToolsFirst.md +77 -0
- package/concepts/zh-TW/TranslateContextPollution.md +46 -0
- package/concepts/zh-TW/UltraPlan.md +55 -0
- package/dist/assets/App-BzrN42Qt.js +41 -0
- package/dist/assets/App-fG2r3jFq.css +1 -0
- package/dist/assets/AppHeader-B3hrnZr-.css +41 -0
- package/dist/assets/AppHeader.module-BMU2vyC4.js +1246 -0
- package/dist/assets/Mobile-BnDkLcBv.css +1 -0
- package/dist/assets/Mobile-C9wxQTXV.js +10 -0
- package/dist/assets/_baseUniq-CXWHShmB.js +1 -0
- package/dist/assets/arc-qbol0NJo.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-COZebb1N.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-COCgzPKy.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-Bs5XvLEo.js +10 -0
- package/dist/assets/channel-DvLUZ3Xf.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CyGriz9d.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-DlT_q-fl.js +206 -0
- package/dist/assets/chunk-55IACEB6-NHN99ZNC.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-fK4f9Na9.js +1 -0
- package/dist/assets/chunk-FMBD7UC4-DL-dQIq1.js +15 -0
- package/dist/assets/chunk-OYMX7WX6-6uruB4VY.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-CCWIqLO8.js +1 -0
- package/dist/assets/chunk-YZCP3GAM-Cu84DMJo.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-ByKhp2Tk.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ByKhp2Tk.js +1 -0
- package/dist/assets/clone-DZHpE494.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-Dqi4iU3-.js +1 -0
- package/dist/assets/cytoscape.esm-DxGcaOPV.js +331 -0
- package/dist/assets/dagre-KV5264BT-Dl-EJ9rb.js +4 -0
- package/dist/assets/default-avatar-CT9tlZjd.svg +1 -0
- package/dist/assets/default-model-avatar-DlbUPjyv.svg +1 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BYtyzF0D.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-BuETHFNU.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-Z02oZp6I.js +43 -0
- package/dist/assets/diagram-TYMM5635-BpGlKv8l.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-C4YngeQh.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-BWddFffg.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-Bg0jOD4m.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-Bz-3sTXZ.js +106 -0
- package/dist/assets/graph-XiH8DkIu.js +1 -0
- package/dist/assets/html2canvas.esm-QH1iLAAe.js +22 -0
- package/dist/assets/index-BuUsWKGZ.js +41 -0
- package/dist/assets/index-DCtZ2KSX.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-DLo-lOCE.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-BsIzT5eO.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-C3bOkKqv.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-D-rJRNbp.js +89 -0
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-Bqp8Hh1c.js +1 -0
- package/dist/assets/linear-BWRSipGb.js +1 -0
- package/dist/assets/loading-pet-12a9GTQC.gif +0 -0
- package/dist/assets/mermaid.core-CJa7klhs.js +308 -0
- package/dist/assets/min-CQ_VyzMd.js +1 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-Bnj7DDSx.js +96 -0
- package/dist/assets/model-deepseek-LDuqTN2N.svg +2 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-CC8a7Ylw.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-CrmL1WUV.js +7 -0
- package/dist/assets/requirementDiagram-MS252O5E-TfkX4u_6.js +84 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMwBbND7.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-CFghMYsm.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-1H6JIM7l.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-CtXMaKcb.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-DnLMBlVa.js +120 -0
- package/dist/assets/vennDiagram-DHZGUBPP-DoIkY_KL.js +34 -0
- package/dist/assets/wardley-RL74JXVD-3n5XmOD5.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-UMOq3ofC.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-CNDuQ5Cg.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.html +15 -0
- package/findcx.js +204 -0
- package/i18n.js +2413 -0
- package/interceptor.js +698 -0
- package/lib/appserver-bridge.js +584 -0
- package/lib/ask-bridge.js +126 -0
- package/lib/codex-config.js +146 -0
- package/lib/constants.js +26 -0
- package/lib/context-watcher.js +85 -0
- package/lib/cxv-editor.js +73 -0
- package/lib/delta-reconstructor.js +256 -0
- package/lib/ensure-hooks.js +88 -0
- package/lib/extract-plugin-name.mjs +18 -0
- package/lib/file-api.js +128 -0
- package/lib/git-diff.js +89 -0
- package/lib/interceptor-core.js +270 -0
- package/lib/kv-cache-analyzer.js +164 -0
- package/lib/log-management.js +198 -0
- package/lib/log-stream.js +310 -0
- package/lib/log-watcher.js +245 -0
- package/lib/otel-adapter.js +251 -0
- package/lib/otel-receiver.js +198 -0
- package/lib/perm-bridge.js +142 -0
- package/lib/plugin-loader.js +161 -0
- package/lib/plugin-manager.js +118 -0
- package/lib/proxy-env.js +23 -0
- package/lib/proxy-errors.js +18 -0
- package/lib/sdk-adapter.js +77 -0
- package/lib/sdk-manager.js +464 -0
- package/lib/stats-worker.js +384 -0
- package/lib/updater.js +123 -0
- package/lib/user-profile.js +43 -0
- package/package.json +115 -0
- package/proxy.js +369 -0
- package/pty-manager.js +407 -0
- package/server.js +3226 -0
- package/workspace-registry.js +151 -0
package/server.js
ADDED
|
@@ -0,0 +1,3226 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createServer as createHttpsServer } from 'node:https';
|
|
3
|
+
import { createConnection } from 'node:net';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, rmSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream, cpSync, copyFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, join, extname, resolve, basename } from 'node:path';
|
|
8
|
+
import { homedir, platform, networkInterfaces } from 'node:os';
|
|
9
|
+
import { execFile, exec, spawn } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { Worker } from 'node:worker_threads';
|
|
12
|
+
import { isPathContained, readFileContent, writeFileContent, resolveFilePath, ERROR_STATUS_MAP } from './lib/file-api.js';
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
|
|
17
|
+
// execFile with stdin input support (for git check-ignore --stdin)
|
|
18
|
+
function execWithStdin(cmd, args, input, options) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn(cmd, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
21
|
+
let stdout = '';
|
|
22
|
+
let stderr = '';
|
|
23
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
24
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
25
|
+
child.on('error', reject);
|
|
26
|
+
child.on('close', code => {
|
|
27
|
+
// git check-ignore exits 1 when no files are ignored — treat as success
|
|
28
|
+
resolve(stdout);
|
|
29
|
+
});
|
|
30
|
+
if (options?.timeout) {
|
|
31
|
+
setTimeout(() => { try { child.kill(); } catch {} reject(new Error('timeout')); }, options.timeout);
|
|
32
|
+
}
|
|
33
|
+
child.stdin.write(input);
|
|
34
|
+
child.stdin.end();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
import { LOG_FILE, _initPromise, _resumeState, resolveResumeChoice, _projectName, _logDir, _cachedApiKey, _cachedAuthHeader, initForWorkspace, resetWorkspace, streamingState, resetStreamingState, _loadProxyProfile, PROFILE_PATH, _defaultConfig } from './interceptor.js';
|
|
38
|
+
import { parseOtlpTraces, writeOtelEntries } from './lib/otel-receiver.js';
|
|
39
|
+
import { LOG_DIR, setLogDir } from './findcx.js';
|
|
40
|
+
import { t, detectLanguage } from './i18n.js';
|
|
41
|
+
import { DEFAULT_START_PORT, DEFAULT_MAX_PORT, MAX_POST_BODY as _MAX_POST_BODY, MAX_UPLOAD_SIZE, SSE_HEARTBEAT_MS, HOOK_TIMEOUT_MS, EDITOR_SESSION_CLEANUP_MS, UPLOAD_DIR } from './lib/constants.js';
|
|
42
|
+
import { checkAndUpdate } from './lib/updater.js';
|
|
43
|
+
import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, getPluginsDir } from './lib/plugin-loader.js';
|
|
44
|
+
import { uploadPlugins, installPluginFromUrl } from './lib/plugin-manager.js';
|
|
45
|
+
import { getUserProfile } from './lib/user-profile.js';
|
|
46
|
+
import { getGitDiffs } from './lib/git-diff.js';
|
|
47
|
+
import { CONTEXT_WINDOW_FILE, readModelContextSize, buildContextWindowEvent, getContextSizeForModel } from './lib/context-watcher.js';
|
|
48
|
+
import { watchLogFile, startWatching, getWatchedFiles, sendEventToClients, sendToClients } from './lib/log-watcher.js';
|
|
49
|
+
import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
|
|
50
|
+
import { listLocalLogs, deleteLogFiles, mergeLogFiles } from './lib/log-management.js';
|
|
51
|
+
import { countLogEntries, streamRawEntriesAsync, readPagedEntries } from './lib/log-stream.js';
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
// 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
|
|
55
|
+
function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
|
|
56
|
+
|
|
57
|
+
// 启动时一次性读取 ~/.codex/settings.json(不 watch)
|
|
58
|
+
let codexSettings = {};
|
|
59
|
+
try {
|
|
60
|
+
const settingsPath = join(homedir(), '.codex', 'settings.json');
|
|
61
|
+
if (existsSync(settingsPath)) {
|
|
62
|
+
codexSettings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
63
|
+
}
|
|
64
|
+
} catch { }
|
|
65
|
+
const isCliMode = process.env.CXV_CLI_MODE === '1';
|
|
66
|
+
const isSdkMode = process.env.CXV_SDK_MODE === '1';
|
|
67
|
+
const isWorkspaceMode = process.env.CXV_WORKSPACE_MODE === '1';
|
|
68
|
+
const _defaultProxyProfiles = { active: 'max', profiles: [{ id: 'max', name: 'Default' }] };
|
|
69
|
+
const _maskApiKey = (k) => k && typeof k === 'string' && k.length > 4 ? '****' + k.slice(-4) : k ? '****' : '';
|
|
70
|
+
const _maskProfiles = (data) => {
|
|
71
|
+
if (!data?.profiles) return data;
|
|
72
|
+
return { ...data, profiles: data.profiles.map(p => p.apiKey ? { ...p, apiKey: _maskApiKey(p.apiKey) } : p) };
|
|
73
|
+
};
|
|
74
|
+
const _isMasked = (k) => typeof k === 'string' && /^\*{4}.{0,4}$/.test(k);
|
|
75
|
+
|
|
76
|
+
// 获取 Codex 进程 PID(CLI 模式下从 pty-manager 获取)
|
|
77
|
+
let _getPtyPidFn = null;
|
|
78
|
+
function getCodexPid() {
|
|
79
|
+
if (!isCliMode) return process.pid;
|
|
80
|
+
if (_getPtyPidFn) return _getPtyPidFn();
|
|
81
|
+
// lazy load 尚未完成,尝试同步获取(pty-manager 可能已被其他路径加载)
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (isCliMode) {
|
|
85
|
+
import('./pty-manager.js').then(m => {
|
|
86
|
+
_getPtyPidFn = m.getPtyPid;
|
|
87
|
+
}).catch(err => {
|
|
88
|
+
console.error('[CX Viewer] Failed to load pty-manager for PID tracking:', err.message);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 统一的文件/目录忽略规则(仅隐藏系统和版本控制目录)
|
|
93
|
+
const IGNORED_PATTERNS = new Set([
|
|
94
|
+
'.git', '.svn', '.hg', '.DS_Store',
|
|
95
|
+
'.idea', '.vscode'
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// 工作区模式:保存 Codex 额外参数,供 launch API 使用
|
|
99
|
+
let _workspaceCodexArgs = [];
|
|
100
|
+
let _workspaceCodexPath = null;
|
|
101
|
+
let _workspaceIsNpmVersion = false;
|
|
102
|
+
let _workspaceLaunched = false; // 工作区是否已经启动了会话
|
|
103
|
+
|
|
104
|
+
// Ask hook bridge state (for PreToolUse AskUserQuestion hook)
|
|
105
|
+
// At most one pending request at a time (Codex Code is single-threaded)
|
|
106
|
+
let pendingAskHook = null; // { questions, res, timer, createdAt }
|
|
107
|
+
|
|
108
|
+
// Permission hook bridge state (for PreToolUse permission approval)
|
|
109
|
+
let pendingPermHook = null; // { toolName, input, res, timer, createdAt }
|
|
110
|
+
|
|
111
|
+
// Editor session state (for $EDITOR intercept)
|
|
112
|
+
const editorSessions = new Map(); // sessionId → { filePath, done, createdAt }
|
|
113
|
+
// Periodically clean up abandoned editor sessions (older than 1 hour)
|
|
114
|
+
const _editorCleanupTimer = setInterval(() => {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
for (const [id, session] of editorSessions) {
|
|
117
|
+
if (now - (session.createdAt || 0) > EDITOR_SESSION_CLEANUP_MS) editorSessions.delete(id);
|
|
118
|
+
}
|
|
119
|
+
}, 60000);
|
|
120
|
+
_editorCleanupTimer.unref(); // Don't keep process alive for cleanup
|
|
121
|
+
let terminalWss = null; // WebSocketServer reference for broadcasting
|
|
122
|
+
let _writeToPty = null; // PTY write function reference (set by setupTerminalWebSocket)
|
|
123
|
+
let _onPtyData = null; // PTY data listener registration (set by setupTerminalWebSocket)
|
|
124
|
+
export function setWorkspaceCodexArgs(args) {
|
|
125
|
+
_workspaceCodexArgs = args;
|
|
126
|
+
}
|
|
127
|
+
export function setWorkspaceCodexPath(path, isNpm) {
|
|
128
|
+
_workspaceCodexPath = path;
|
|
129
|
+
_workspaceIsNpmVersion = isNpm;
|
|
130
|
+
}
|
|
131
|
+
let _launchCallback = null;
|
|
132
|
+
export function setLaunchCallback(fn) { _launchCallback = fn; }
|
|
133
|
+
export function setWorkspaceLaunched(v) { _workspaceLaunched = v; }
|
|
134
|
+
export function initPostLaunch() {
|
|
135
|
+
watchLogFile(_logWatcherOpts(LOG_FILE));
|
|
136
|
+
if (!statsWorker) startStatsWorker();
|
|
137
|
+
startStreamingStatusTimer();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Global POST body size limit
|
|
141
|
+
const MAX_POST_BODY = _MAX_POST_BODY;
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
146
|
+
const __dirname = dirname(__filename);
|
|
147
|
+
const START_PORT = parseInt(process.env.CXV_START_PORT) || DEFAULT_START_PORT;
|
|
148
|
+
const MAX_PORT = parseInt(process.env.CXV_MAX_PORT) || DEFAULT_MAX_PORT;
|
|
149
|
+
const HOST = '0.0.0.0';
|
|
150
|
+
|
|
151
|
+
// 局域网访问 token(本地 127.0.0.1 免验证)
|
|
152
|
+
const ACCESS_TOKEN = randomBytes(16).toString('hex');
|
|
153
|
+
|
|
154
|
+
let clients = [];
|
|
155
|
+
let server;
|
|
156
|
+
let actualPort = 0;
|
|
157
|
+
let serverProtocol = 'http';
|
|
158
|
+
// Stats Worker 实例
|
|
159
|
+
let statsWorker = null;
|
|
160
|
+
|
|
161
|
+
function startStatsWorker() {
|
|
162
|
+
try {
|
|
163
|
+
statsWorker = new Worker(new URL('./lib/stats-worker.js', import.meta.url));
|
|
164
|
+
statsWorker.on('error', (err) => {
|
|
165
|
+
console.error('[CX Viewer] Stats worker error:', err.message);
|
|
166
|
+
statsWorker = null;
|
|
167
|
+
});
|
|
168
|
+
statsWorker.on('exit', (code) => {
|
|
169
|
+
if (code !== 0) {
|
|
170
|
+
console.error('[CX Viewer] Stats worker exited with code', code);
|
|
171
|
+
}
|
|
172
|
+
statsWorker = null;
|
|
173
|
+
});
|
|
174
|
+
// 初始化:全量扫描当前项目
|
|
175
|
+
if (_projectName && _logDir) {
|
|
176
|
+
statsWorker.postMessage({ type: 'init', logDir: LOG_DIR, projectName: _projectName });
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error('[CX Viewer] Failed to start stats worker:', err.message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function notifyStatsWorker(logFile) {
|
|
184
|
+
if (statsWorker && _projectName) {
|
|
185
|
+
statsWorker.postMessage({ type: 'update', logDir: LOG_DIR, projectName: _projectName, logFile });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const MIME_TYPES = {
|
|
190
|
+
'.html': 'text/html; charset=utf-8',
|
|
191
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
192
|
+
'.css': 'text/css; charset=utf-8',
|
|
193
|
+
'.json': 'application/json; charset=utf-8',
|
|
194
|
+
'.svg': 'image/svg+xml',
|
|
195
|
+
'.png': 'image/png',
|
|
196
|
+
'.jpg': 'image/jpeg',
|
|
197
|
+
'.woff': 'font/woff',
|
|
198
|
+
'.woff2': 'font/woff2',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Helper to build log-watcher options object
|
|
202
|
+
function _logWatcherOpts(logFile) {
|
|
203
|
+
return {
|
|
204
|
+
logFile: logFile || LOG_FILE,
|
|
205
|
+
clients,
|
|
206
|
+
getCodexPid,
|
|
207
|
+
runParallelHook,
|
|
208
|
+
notifyStatsWorker,
|
|
209
|
+
getLogFile: () => LOG_FILE,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getLocalIp() {
|
|
214
|
+
const nets = networkInterfaces();
|
|
215
|
+
for (const name of Object.keys(nets)) {
|
|
216
|
+
for (const net of nets[name]) {
|
|
217
|
+
if (net.family === 'IPv4' && !net.internal) return net.address;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return '127.0.0.1';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getAllLocalIps() {
|
|
224
|
+
const ips = [];
|
|
225
|
+
const nets = networkInterfaces();
|
|
226
|
+
for (const name of Object.keys(nets)) {
|
|
227
|
+
for (const net of nets[name]) {
|
|
228
|
+
if (net.family === 'IPv4' && !net.internal) ips.push(net.address);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return ips;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function handleRequest(req, res) {
|
|
235
|
+
const parsedUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
|
|
236
|
+
const url = parsedUrl.pathname;
|
|
237
|
+
const method = req.method;
|
|
238
|
+
|
|
239
|
+
// Debug: log OTel requests to file (temporary)
|
|
240
|
+
if (url.startsWith('/v1/')) {
|
|
241
|
+
try { appendFileSync('/tmp/cxv-otel.log', `${new Date().toISOString()} ${method} ${url} ct=${req.headers['content-type']||'-'}\n`); } catch {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// WebSocket 路径不处理,交给 upgrade 事件
|
|
245
|
+
if (url === '/ws/terminal') {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// CORS headers
|
|
250
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
251
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
252
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
253
|
+
|
|
254
|
+
if (method === 'OPTIONS') {
|
|
255
|
+
res.writeHead(200);
|
|
256
|
+
res.end();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 局域网访问 token 验证(本地 127.0.0.1 / ::1 免验证,静态资源免验证)
|
|
261
|
+
const remoteIp = req.socket.remoteAddress;
|
|
262
|
+
const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
|
|
263
|
+
const isStaticAsset = url.startsWith('/assets/') || url === '/favicon.ico';
|
|
264
|
+
if (!isLocal && !isStaticAsset) {
|
|
265
|
+
const urlToken = parsedUrl.searchParams.get('token');
|
|
266
|
+
if (urlToken !== ACCESS_TOKEN) {
|
|
267
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
268
|
+
res.end(JSON.stringify({ error: 'Forbidden: invalid token' }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// OTLP HTTP 接收端点 — 接收 Codex 原生 OTel trace 数据
|
|
274
|
+
if (url === '/v1/traces' && method === 'POST') {
|
|
275
|
+
const buffers = [];
|
|
276
|
+
for await (const chunk of req) buffers.push(chunk);
|
|
277
|
+
const body = Buffer.concat(buffers);
|
|
278
|
+
try {
|
|
279
|
+
const otlpData = JSON.parse(body.toString());
|
|
280
|
+
const entries = parseOtlpTraces(otlpData);
|
|
281
|
+
if (entries.length > 0 && LOG_FILE) {
|
|
282
|
+
writeOtelEntries(LOG_FILE, entries);
|
|
283
|
+
}
|
|
284
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
285
|
+
res.end('{}');
|
|
286
|
+
} catch {
|
|
287
|
+
// 可能是 protobuf 格式,暂时忽略
|
|
288
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
289
|
+
res.end('{}');
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// OTLP logs/metrics endpoints (Codex may also send these)
|
|
295
|
+
if ((url === '/v1/logs' || url === '/v1/metrics') && method === 'POST') {
|
|
296
|
+
console.error(`[OTel] Received ${method} ${url} (${req.headers['content-type'] || 'no-ct'})`);
|
|
297
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
298
|
+
res.end('{}');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// User preferences API
|
|
303
|
+
// File upload API — save to /tmp/cx-viewer-uploads/
|
|
304
|
+
if (url === '/api/upload' && method === 'POST') {
|
|
305
|
+
const contentType = req.headers['content-type'] || '';
|
|
306
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
307
|
+
if (!boundaryMatch) {
|
|
308
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
309
|
+
res.end(JSON.stringify({ error: 'Missing boundary' }));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const MAX_UPLOAD = MAX_UPLOAD_SIZE;
|
|
313
|
+
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
314
|
+
if (contentLength > MAX_UPLOAD) {
|
|
315
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
316
|
+
res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const boundary = boundaryMatch[1];
|
|
320
|
+
const chunks = [];
|
|
321
|
+
let totalSize = 0;
|
|
322
|
+
let aborted = false;
|
|
323
|
+
req.on('data', chunk => {
|
|
324
|
+
totalSize += chunk.length;
|
|
325
|
+
if (totalSize > MAX_UPLOAD) {
|
|
326
|
+
aborted = true;
|
|
327
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
328
|
+
res.end(JSON.stringify({ error: 'File too large (max 50MB)' }));
|
|
329
|
+
req.destroy();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
chunks.push(chunk);
|
|
333
|
+
});
|
|
334
|
+
req.on('end', () => {
|
|
335
|
+
if (aborted) return;
|
|
336
|
+
try {
|
|
337
|
+
const buf = Buffer.concat(chunks);
|
|
338
|
+
// Find the first part's headers and body
|
|
339
|
+
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
340
|
+
if (headerEnd === -1) throw new Error('Malformed multipart');
|
|
341
|
+
const headerStr = buf.slice(0, headerEnd).toString();
|
|
342
|
+
const nameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
343
|
+
if (!nameMatch) throw new Error('No filename');
|
|
344
|
+
const originalName = nameMatch[1].replace(/[/\\]/g, '_'); // sanitize
|
|
345
|
+
const bodyStart = headerEnd + 4;
|
|
346
|
+
// Find the closing boundary
|
|
347
|
+
const closingBoundary = Buffer.from('\r\n--' + boundary);
|
|
348
|
+
const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
|
|
349
|
+
const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
|
|
350
|
+
const uploadDir = UPLOAD_DIR;
|
|
351
|
+
mkdirSync(uploadDir, { recursive: true });
|
|
352
|
+
// Unique filename: prepend timestamp to avoid silent overwrite
|
|
353
|
+
const ts = Date.now();
|
|
354
|
+
const dotIdx = originalName.lastIndexOf('.');
|
|
355
|
+
const uniqueName = dotIdx > 0
|
|
356
|
+
? `${originalName.slice(0, dotIdx)}-${ts}${originalName.slice(dotIdx)}`
|
|
357
|
+
: `${originalName}-${ts}`;
|
|
358
|
+
const savePath = join(uploadDir, uniqueName);
|
|
359
|
+
writeFileSync(savePath, fileData);
|
|
360
|
+
// 持久化副本到 ~/.codex/cx-viewer/${project}/images/,避免 /tmp 清理后丢失
|
|
361
|
+
let persistPath = null;
|
|
362
|
+
try {
|
|
363
|
+
const pName = _projectName || 'default';
|
|
364
|
+
const persistDir = join(homedir(), '.codex', 'cx-viewer', pName, 'images');
|
|
365
|
+
mkdirSync(persistDir, { recursive: true });
|
|
366
|
+
persistPath = join(persistDir, uniqueName);
|
|
367
|
+
writeFileSync(persistPath, fileData);
|
|
368
|
+
} catch { }
|
|
369
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
370
|
+
res.end(JSON.stringify({ ok: true, path: savePath, persistPath }));
|
|
371
|
+
} catch (err) {
|
|
372
|
+
console.error('upload error:', err);
|
|
373
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
374
|
+
res.end(JSON.stringify({ error: 'Upload failed' }));
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Import file directly into project directory
|
|
381
|
+
if (url.startsWith('/api/import-file') && method === 'POST') {
|
|
382
|
+
const contentType = req.headers['content-type'] || '';
|
|
383
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
384
|
+
if (!boundaryMatch) {
|
|
385
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
386
|
+
res.end(JSON.stringify({ error: 'Missing boundary' }));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const importUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
|
|
390
|
+
const dir = importUrl.searchParams.get('dir') || '';
|
|
391
|
+
// Security: reject absolute paths and path traversal
|
|
392
|
+
if (dir.startsWith('/') || dir.includes('..')) {
|
|
393
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
394
|
+
res.end(JSON.stringify({ error: 'Invalid dir parameter' }));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const MAX_UPLOAD = MAX_UPLOAD_SIZE;
|
|
398
|
+
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
399
|
+
if (contentLength > MAX_UPLOAD) {
|
|
400
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
401
|
+
res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const boundary = boundaryMatch[1];
|
|
405
|
+
const chunks = [];
|
|
406
|
+
let totalSize = 0;
|
|
407
|
+
let aborted = false;
|
|
408
|
+
req.on('data', chunk => {
|
|
409
|
+
totalSize += chunk.length;
|
|
410
|
+
if (totalSize > MAX_UPLOAD) {
|
|
411
|
+
aborted = true;
|
|
412
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
413
|
+
res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
|
|
414
|
+
req.destroy();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
chunks.push(chunk);
|
|
418
|
+
});
|
|
419
|
+
req.on('end', () => {
|
|
420
|
+
if (aborted) return;
|
|
421
|
+
try {
|
|
422
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
423
|
+
const targetDir = join(cwd, dir);
|
|
424
|
+
mkdirSync(targetDir, { recursive: true });
|
|
425
|
+
const realDir = realpathSync(targetDir);
|
|
426
|
+
const realCwd = realpathSync(cwd);
|
|
427
|
+
if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
|
|
428
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
429
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const buf = Buffer.concat(chunks);
|
|
433
|
+
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
434
|
+
if (headerEnd === -1) throw new Error('Malformed multipart');
|
|
435
|
+
const headerStr = buf.slice(0, headerEnd).toString();
|
|
436
|
+
const nameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
437
|
+
if (!nameMatch) throw new Error('No filename');
|
|
438
|
+
const originalName = nameMatch[1].replace(/[/\\]/g, '_');
|
|
439
|
+
const bodyStart = headerEnd + 4;
|
|
440
|
+
const closingBoundary = Buffer.from('\r\n--' + boundary);
|
|
441
|
+
const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
|
|
442
|
+
const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
|
|
443
|
+
// Resolve unique filename: append -1, -2, ... if conflict
|
|
444
|
+
const dotIdx = originalName.lastIndexOf('.');
|
|
445
|
+
const stem = dotIdx > 0 ? originalName.slice(0, dotIdx) : originalName;
|
|
446
|
+
const ext = dotIdx > 0 ? originalName.slice(dotIdx) : '';
|
|
447
|
+
let finalName = originalName;
|
|
448
|
+
let savePath = join(realDir, finalName);
|
|
449
|
+
let counter = 1;
|
|
450
|
+
while (existsSync(savePath)) {
|
|
451
|
+
finalName = `${stem}-${counter}${ext}`;
|
|
452
|
+
savePath = join(realDir, finalName);
|
|
453
|
+
counter++;
|
|
454
|
+
}
|
|
455
|
+
writeFileSync(savePath, fileData);
|
|
456
|
+
const relPath = dir ? `${dir}/${finalName}` : finalName;
|
|
457
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
458
|
+
res.end(JSON.stringify({ ok: true, name: finalName, relPath }));
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.error('import-file error:', err);
|
|
461
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
462
|
+
res.end(JSON.stringify({ error: 'Import failed' }));
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (url === '/api/preferences' && method === 'GET') {
|
|
469
|
+
let prefs = {};
|
|
470
|
+
try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
|
|
471
|
+
prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
|
|
472
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
473
|
+
res.end(JSON.stringify(prefs));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (url === '/api/preferences' && method === 'POST') {
|
|
478
|
+
let body = '';
|
|
479
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
480
|
+
req.on('end', () => {
|
|
481
|
+
try {
|
|
482
|
+
const incoming = JSON.parse(body);
|
|
483
|
+
// 如果修改了日志目录,先切换再保存到新位置(新目录下生成 preferences.json)
|
|
484
|
+
if (incoming.logDir && typeof incoming.logDir === 'string') {
|
|
485
|
+
setLogDir(incoming.logDir);
|
|
486
|
+
}
|
|
487
|
+
let prefs = {};
|
|
488
|
+
try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
|
|
489
|
+
Object.assign(prefs, incoming);
|
|
490
|
+
// 确保目录存在
|
|
491
|
+
const prefsFile = getPrefsFile();
|
|
492
|
+
const prefsDir = dirname(prefsFile);
|
|
493
|
+
if (!existsSync(prefsDir)) mkdirSync(prefsDir, { recursive: true });
|
|
494
|
+
writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
|
|
495
|
+
// 主题切换时同步到 Codex Code CLI:发 /theme,监听输出验证结果,不对就再发一次
|
|
496
|
+
if (incoming.themeColor && _writeToPty && _onPtyData) {
|
|
497
|
+
const target = incoming.themeColor === 'light' ? 'light' : 'dark';
|
|
498
|
+
let buf = '';
|
|
499
|
+
let retried = false;
|
|
500
|
+
const removeListener = _onPtyData((data) => {
|
|
501
|
+
buf += data;
|
|
502
|
+
if (buf.length > 4096) buf = buf.slice(-2048); // 限制 buf 大小
|
|
503
|
+
// 解析 PTY 输出中的 "Theme set to light" 或 "Theme set to dark"
|
|
504
|
+
const match = buf.match(/Theme set to (light|dark)/);
|
|
505
|
+
if (match) {
|
|
506
|
+
removeListener();
|
|
507
|
+
clearTimeout(timeout);
|
|
508
|
+
if (match[1] !== target && !retried) {
|
|
509
|
+
// 结果与目标不一致,再 toggle 一次
|
|
510
|
+
retried = true;
|
|
511
|
+
try { _writeToPty('/theme\r'); } catch {}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
// 5 秒超时,避免监听器泄漏
|
|
516
|
+
const timeout = setTimeout(() => { removeListener(); }, 5000);
|
|
517
|
+
try { _writeToPty('/theme\r'); } catch {}
|
|
518
|
+
}
|
|
519
|
+
prefs.logDir = LOG_DIR;
|
|
520
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
521
|
+
res.end(JSON.stringify(prefs));
|
|
522
|
+
} catch {
|
|
523
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
524
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
|
|
531
|
+
if (url === '/api/register-log' && method === 'POST') {
|
|
532
|
+
let body = '';
|
|
533
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
534
|
+
req.on('end', () => {
|
|
535
|
+
try {
|
|
536
|
+
const { logFile } = JSON.parse(body);
|
|
537
|
+
if (logFile && typeof logFile === 'string' && logFile.startsWith(LOG_DIR) && existsSync(logFile)) {
|
|
538
|
+
watchLogFile(_logWatcherOpts(logFile));
|
|
539
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
540
|
+
res.end(JSON.stringify({ ok: true }));
|
|
541
|
+
} else {
|
|
542
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
543
|
+
res.end(JSON.stringify({ error: 'Invalid log file path' }));
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
547
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// 用户选择继续/新开日志
|
|
554
|
+
if (url === '/api/resume-choice' && method === 'POST') {
|
|
555
|
+
let body = '';
|
|
556
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
557
|
+
req.on('end', async () => {
|
|
558
|
+
try {
|
|
559
|
+
const { choice } = JSON.parse(body);
|
|
560
|
+
if (choice !== 'continue' && choice !== 'new') {
|
|
561
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
562
|
+
res.end(JSON.stringify({ error: 'Invalid choice' }));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const result = resolveResumeChoice(choice);
|
|
566
|
+
if (!result) {
|
|
567
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
568
|
+
res.end(JSON.stringify({ error: 'Already resolved' }));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
// 重新 watch 最终的日志文件
|
|
572
|
+
watchLogFile(_logWatcherOpts(result.logFile));
|
|
573
|
+
// 广播 resume_resolved + full_reload
|
|
574
|
+
const resolvedData = JSON.stringify({ logFile: result.logFile });
|
|
575
|
+
clients.forEach(client => {
|
|
576
|
+
try {
|
|
577
|
+
client.write(`event: resume_resolved\ndata: ${resolvedData}\n\n`);
|
|
578
|
+
} catch { }
|
|
579
|
+
});
|
|
580
|
+
// 流式分段广播 full_reload,避免全量加载 OOM
|
|
581
|
+
const reloadTotal = countLogEntries(LOG_FILE);
|
|
582
|
+
clients.forEach(client => {
|
|
583
|
+
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: reloadTotal, incremental: false })}\n\n`); } catch { }
|
|
584
|
+
});
|
|
585
|
+
await streamRawEntriesAsync(LOG_FILE, (raw) => {
|
|
586
|
+
clients.forEach(client => {
|
|
587
|
+
try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch { }
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
clients.forEach(client => {
|
|
591
|
+
try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
|
|
592
|
+
});
|
|
593
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
594
|
+
res.end(JSON.stringify({ ok: true, logFile: result.logFile }));
|
|
595
|
+
} catch {
|
|
596
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
597
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// === Workspace API ===
|
|
604
|
+
|
|
605
|
+
// 目录浏览器
|
|
606
|
+
if (url.startsWith('/api/browse-dir') && method === 'GET') {
|
|
607
|
+
try {
|
|
608
|
+
const dirPath = parsedUrl.searchParams.get('path') || homedir();
|
|
609
|
+
if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
|
|
610
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
611
|
+
res.end(JSON.stringify({ error: 'Invalid directory' }));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
615
|
+
const dirs = [];
|
|
616
|
+
for (const entry of entries) {
|
|
617
|
+
if (!entry.isDirectory()) continue;
|
|
618
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
619
|
+
const fullPath = join(dirPath, entry.name);
|
|
620
|
+
let hasGit = false;
|
|
621
|
+
try { hasGit = existsSync(join(fullPath, '.git')); } catch {}
|
|
622
|
+
dirs.push({ name: entry.name, path: fullPath, hasGit });
|
|
623
|
+
}
|
|
624
|
+
dirs.sort((a, b) => {
|
|
625
|
+
if (a.hasGit !== b.hasGit) return a.hasGit ? -1 : 1;
|
|
626
|
+
return a.name.localeCompare(b.name);
|
|
627
|
+
});
|
|
628
|
+
const parent = join(dirPath, '..');
|
|
629
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
630
|
+
res.end(JSON.stringify({ current: dirPath, parent: parent !== dirPath ? parent : null, dirs }));
|
|
631
|
+
} catch (err) {
|
|
632
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
633
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (url === '/api/workspaces' && method === 'GET') {
|
|
639
|
+
import('./workspace-registry.js').then(({ getWorkspaces }) => {
|
|
640
|
+
const workspaces = getWorkspaces();
|
|
641
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
642
|
+
res.end(JSON.stringify({ workspaces, workspaceMode: isWorkspaceMode && !_workspaceLaunched }));
|
|
643
|
+
}).catch(err => {
|
|
644
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
645
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (url === '/api/workspaces/launch' && method === 'POST') {
|
|
651
|
+
let body = '';
|
|
652
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
653
|
+
req.on('end', async () => {
|
|
654
|
+
try {
|
|
655
|
+
const { path: wsPath, extraArgs: launchExtraArgs } = JSON.parse(body);
|
|
656
|
+
if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
|
|
657
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
658
|
+
res.end(JSON.stringify({ error: 'Invalid directory path' }));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const { registerWorkspace } = await import('./workspace-registry.js');
|
|
663
|
+
registerWorkspace(wsPath);
|
|
664
|
+
|
|
665
|
+
// Electron multi-tab 模式:管理 server 只触发 callback,不做日志初始化
|
|
666
|
+
// 所有日志相关操作(initForWorkspace、watchLogFile、spawnCodex)由 tab-worker 子进程负责
|
|
667
|
+
if (process.env.CXV_ELECTRON_MULTITAB === '1') {
|
|
668
|
+
if (_launchCallback) {
|
|
669
|
+
_launchCallback(wsPath, Array.isArray(launchExtraArgs) ? launchExtraArgs : []);
|
|
670
|
+
}
|
|
671
|
+
_workspaceLaunched = true;
|
|
672
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
673
|
+
res.end(JSON.stringify({ ok: true, projectName: basename(wsPath) }));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// 非 Electron 模式(web / CLI):完整逻辑
|
|
678
|
+
const result = initForWorkspace(wsPath);
|
|
679
|
+
process.env.CXV_PROJECT_DIR = wsPath;
|
|
680
|
+
|
|
681
|
+
// 启动日志监听
|
|
682
|
+
watchLogFile(_logWatcherOpts(LOG_FILE));
|
|
683
|
+
|
|
684
|
+
// 启动 stats worker(如果尚未启动)
|
|
685
|
+
if (!statsWorker) startStatsWorker();
|
|
686
|
+
startStreamingStatusTimer();
|
|
687
|
+
|
|
688
|
+
// 启动 PTY
|
|
689
|
+
const proxyPort = process.env.CXV_PROXY_PORT;
|
|
690
|
+
if (proxyPort) {
|
|
691
|
+
const { spawnCodex } = await import('./pty-manager.js');
|
|
692
|
+
const mergedArgs = [..._workspaceCodexArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
|
|
693
|
+
await spawnCodex(parseInt(proxyPort), wsPath, mergedArgs, _workspaceCodexPath, _workspaceIsNpmVersion, actualPort);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
_workspaceLaunched = true;
|
|
697
|
+
|
|
698
|
+
// 通知所有 SSE 客户端
|
|
699
|
+
clients.forEach(client => {
|
|
700
|
+
try {
|
|
701
|
+
client.write(`event: workspace_started\ndata: ${JSON.stringify({ projectName: result.projectName, path: wsPath })}\n\n`);
|
|
702
|
+
} catch {}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// 流式分段广播以刷新会话区域,避免全量加载 OOM
|
|
706
|
+
const wsReloadTotal = countLogEntries(LOG_FILE);
|
|
707
|
+
clients.forEach(client => {
|
|
708
|
+
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
|
|
709
|
+
});
|
|
710
|
+
await streamRawEntriesAsync(LOG_FILE, (raw) => {
|
|
711
|
+
clients.forEach(client => {
|
|
712
|
+
try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch {}
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
clients.forEach(client => {
|
|
716
|
+
try { client.write(`event: load_end\ndata: {}\n\n`); } catch {}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
720
|
+
res.end(JSON.stringify({ ok: true, projectName: result.projectName }));
|
|
721
|
+
} catch (err) {
|
|
722
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
723
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (url === '/api/workspaces/add' && method === 'POST') {
|
|
730
|
+
let body = '';
|
|
731
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
732
|
+
req.on('end', async () => {
|
|
733
|
+
try {
|
|
734
|
+
const { path: wsPath } = JSON.parse(body);
|
|
735
|
+
if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
|
|
736
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
737
|
+
res.end(JSON.stringify({ error: 'Invalid directory path' }));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const { registerWorkspace } = await import('./workspace-registry.js');
|
|
741
|
+
const entry = registerWorkspace(wsPath);
|
|
742
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
743
|
+
res.end(JSON.stringify({ ok: true, workspace: entry }));
|
|
744
|
+
} catch (err) {
|
|
745
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
746
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (url.startsWith('/api/workspaces/') && method === 'DELETE') {
|
|
753
|
+
const id = url.split('/').pop();
|
|
754
|
+
import('./workspace-registry.js').then(({ removeWorkspace }) => {
|
|
755
|
+
const removed = removeWorkspace(id);
|
|
756
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
757
|
+
res.end(JSON.stringify({ ok: removed }));
|
|
758
|
+
}).catch(err => {
|
|
759
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
760
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
761
|
+
});
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (url === '/api/workspaces/stop' && method === 'POST') {
|
|
766
|
+
import('./pty-manager.js').then(({ killPty }) => {
|
|
767
|
+
killPty();
|
|
768
|
+
|
|
769
|
+
// 停止日志监听
|
|
770
|
+
for (const logFile of getWatchedFiles().keys()) {
|
|
771
|
+
unwatchFile(logFile);
|
|
772
|
+
}
|
|
773
|
+
getWatchedFiles().clear();
|
|
774
|
+
|
|
775
|
+
// 重置 interceptor 状态
|
|
776
|
+
resetWorkspace();
|
|
777
|
+
_workspaceLaunched = false;
|
|
778
|
+
|
|
779
|
+
// 通知所有 SSE 客户端
|
|
780
|
+
clients.forEach(client => {
|
|
781
|
+
try {
|
|
782
|
+
client.write(`event: workspace_stopped\ndata: {}\n\n`);
|
|
783
|
+
} catch {}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
787
|
+
res.end(JSON.stringify({ ok: true }));
|
|
788
|
+
}).catch(err => {
|
|
789
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
790
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// SSE endpoint
|
|
796
|
+
if (url === '/events' && method === 'GET') {
|
|
797
|
+
res.writeHead(200, {
|
|
798
|
+
'Content-Type': 'text/event-stream',
|
|
799
|
+
'Cache-Control': 'no-cache',
|
|
800
|
+
'Connection': 'keep-alive',
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// 注意:不要在此处 clients.push(res)!
|
|
804
|
+
// 必须等 load_end + kv_cache + context_window 全部发送完毕后再加入广播列表,
|
|
805
|
+
// 否则 streamRawEntriesAsync 的 setImmediate yield 间隙会让 watcher 的
|
|
806
|
+
// sendToClients 向该客户端推送 live entry,而 load_end 的 setState 会覆盖这些
|
|
807
|
+
// 已处理的 live entry,导致 对话条目"显示→消失→重现"闪烁。
|
|
808
|
+
|
|
809
|
+
// SSE 心跳保活:防止连接被 OS/代理/浏览器静默断开
|
|
810
|
+
const pingTimer = setInterval(() => {
|
|
811
|
+
try { res.write('event: ping\ndata: {}\n\n'); } catch {}
|
|
812
|
+
}, SSE_HEARTBEAT_MS);
|
|
813
|
+
|
|
814
|
+
// 如果有待决的 resume 选择,发送 resume_prompt 事件
|
|
815
|
+
if (_resumeState) {
|
|
816
|
+
res.write(`event: resume_prompt\ndata: ${JSON.stringify({ recentFileName: _resumeState.recentFileName })}\n\n`);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// 增量加载参数:移动端带 since/cc/project 请求增量数据
|
|
820
|
+
const sinceParam = parsedUrl.searchParams.get('since');
|
|
821
|
+
const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
|
|
822
|
+
const projectParam = parsedUrl.searchParams.get('project');
|
|
823
|
+
const projectMatch = !projectParam || projectParam === (_projectName || '');
|
|
824
|
+
const useIncremental = !!(sinceParam && ccParam > 0 && projectMatch && !isNaN(new Date(sinceParam).getTime()));
|
|
825
|
+
|
|
826
|
+
// 分页参数:移动端首次加载传 limit=200,与 since 互斥
|
|
827
|
+
const limitParam = parseInt(parsedUrl.searchParams.get('limit'), 10) || 0;
|
|
828
|
+
const useLimit = !useIncremental && limitParam > 0;
|
|
829
|
+
|
|
830
|
+
// KV-Cache / context_window 追踪(扫描全量条目,不受 since 过滤影响)
|
|
831
|
+
let latestKvCache = null;
|
|
832
|
+
let latestContextWindow = null;
|
|
833
|
+
let pushedContextWindow = false;
|
|
834
|
+
|
|
835
|
+
await streamRawEntriesAsync(LOG_FILE, (raw) => {
|
|
836
|
+
// 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
|
|
837
|
+
// SSE data 字段不允许裸换行,去除 pretty-printed JSON 的换行
|
|
838
|
+
res.write('event: load_chunk\ndata: [');
|
|
839
|
+
res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
|
|
840
|
+
res.write(']\n\n');
|
|
841
|
+
}, {
|
|
842
|
+
since: useIncremental ? sinceParam : undefined,
|
|
843
|
+
limit: useLimit ? limitParam : undefined,
|
|
844
|
+
onScan: (raw) => {
|
|
845
|
+
// 轻量追踪最新 MainAgent 的 KV-Cache 和 context_window(仅 regex 检测)
|
|
846
|
+
if (raw.includes('"mainAgent":true') || raw.includes('"mainAgent": true')) {
|
|
847
|
+
try {
|
|
848
|
+
const entry = JSON.parse(raw);
|
|
849
|
+
if (isMainAgentEntry(entry)) {
|
|
850
|
+
const cached = extractCachedContent(entry);
|
|
851
|
+
if (cached) latestKvCache = cached;
|
|
852
|
+
const usage = entry.response?.body?.usage;
|
|
853
|
+
if (usage) {
|
|
854
|
+
const contextSize = getContextSizeForModel(entry.body?.model);
|
|
855
|
+
const cw = buildContextWindowEvent(usage, contextSize);
|
|
856
|
+
if (cw) latestContextWindow = cw;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
} catch { }
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
onReady: ({ totalCount, hasMore, oldestTs }) => {
|
|
863
|
+
// Pass 1 完成、Pass 2 开始前:发送 load_start
|
|
864
|
+
// 增量模式下不显示 loading 遮罩,非增量模式显示进度
|
|
865
|
+
const loadStartData = { total: totalCount, incremental: !!useIncremental };
|
|
866
|
+
// 分页模式下附加 hasMore/oldestTs(增量模式由客户端从缓存自行判断)
|
|
867
|
+
if (useLimit) {
|
|
868
|
+
loadStartData.hasMore = !!hasMore;
|
|
869
|
+
loadStartData.oldestTs = oldestTs || '';
|
|
870
|
+
}
|
|
871
|
+
res.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
res.write(`event: load_end\ndata: {}\n\n`);
|
|
876
|
+
|
|
877
|
+
// 发送最新 MainAgent 的 KV-Cache 和 context_window
|
|
878
|
+
if (latestKvCache) {
|
|
879
|
+
res.write(`event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
|
|
880
|
+
}
|
|
881
|
+
if (latestContextWindow) {
|
|
882
|
+
res.write(`event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
|
|
883
|
+
pushedContextWindow = true;
|
|
884
|
+
}
|
|
885
|
+
// Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
|
|
886
|
+
if (!pushedContextWindow) {
|
|
887
|
+
try {
|
|
888
|
+
const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
|
|
889
|
+
const cwFile = JSON.parse(cwRaw);
|
|
890
|
+
if (cwFile?.context_window) {
|
|
891
|
+
// Recalculate with correct context size from model.id
|
|
892
|
+
const { contextSize } = readModelContextSize();
|
|
893
|
+
const cw = cwFile.context_window;
|
|
894
|
+
const inputTokens = cw.total_input_tokens || 0;
|
|
895
|
+
const outputTokens = cw.total_output_tokens || 0;
|
|
896
|
+
const totalTokens = inputTokens + outputTokens;
|
|
897
|
+
const usedPct = contextSize > 0 ? Math.round((totalTokens / contextSize) * 100) : 0;
|
|
898
|
+
const data = { ...cw, context_window_size: contextSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
|
|
899
|
+
res.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
|
|
900
|
+
}
|
|
901
|
+
} catch { }
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// 历史数据 + KV-Cache + context_window 全部发送完毕后,才将客户端加入广播列表。
|
|
905
|
+
// 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
|
|
906
|
+
clients.push(res);
|
|
907
|
+
|
|
908
|
+
req.on('close', () => {
|
|
909
|
+
clearInterval(pingTimer);
|
|
910
|
+
const idx = clients.indexOf(res);
|
|
911
|
+
if (idx !== -1) clients.splice(idx, 1);
|
|
912
|
+
});
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// API endpoint
|
|
917
|
+
if (url === '/api/requests' && method === 'GET') {
|
|
918
|
+
// 异步流式 JSON 数组输出,不做 reconstruct,发原始条目
|
|
919
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
920
|
+
res.write('[');
|
|
921
|
+
let first = true;
|
|
922
|
+
await streamRawEntriesAsync(LOG_FILE, (raw) => {
|
|
923
|
+
if (!first) res.write(',');
|
|
924
|
+
res.write(raw);
|
|
925
|
+
first = false;
|
|
926
|
+
});
|
|
927
|
+
res.write(']');
|
|
928
|
+
res.end();
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// 分页历史条目端点:移动端"加载更多"按需拉取
|
|
933
|
+
if (url === '/api/entries/page' && method === 'GET') {
|
|
934
|
+
const before = parsedUrl.searchParams.get('before');
|
|
935
|
+
const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 100, 500);
|
|
936
|
+
if (!before || isNaN(new Date(before).getTime())) {
|
|
937
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
938
|
+
res.end(JSON.stringify({ error: 'missing or invalid "before" parameter' }));
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
const result = readPagedEntries(LOG_FILE, { before, limit: limitVal });
|
|
943
|
+
// entries 是原始 JSON 字符串数组,parse 后返回给客户端
|
|
944
|
+
const entries = result.entries.map(raw => {
|
|
945
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
946
|
+
}).filter(Boolean);
|
|
947
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
948
|
+
res.end(JSON.stringify({
|
|
949
|
+
entries,
|
|
950
|
+
hasMore: result.hasMore,
|
|
951
|
+
oldestTimestamp: result.oldestTimestamp,
|
|
952
|
+
count: entries.length,
|
|
953
|
+
}));
|
|
954
|
+
} catch (err) {
|
|
955
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
956
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// 当前监控的项目名称
|
|
962
|
+
if (url === '/api/project-name' && method === 'GET') {
|
|
963
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
964
|
+
res.end(JSON.stringify({ projectName: _projectName || '' }));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// 返回项目目录绝对路径(前端用于将绝对路径转为相对路径)
|
|
969
|
+
if (url === '/api/project-dir' && method === 'GET') {
|
|
970
|
+
const dir = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
971
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
972
|
+
res.end(JSON.stringify({ dir }));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// 当前版本号
|
|
977
|
+
if (url === '/api/version-info' && method === 'GET') {
|
|
978
|
+
try {
|
|
979
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
|
|
980
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
981
|
+
res.end(JSON.stringify({ version: pkg.version }));
|
|
982
|
+
} catch {
|
|
983
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
984
|
+
res.end(JSON.stringify({ error: 'Failed to read version' }));
|
|
985
|
+
}
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// 项目统计数据
|
|
990
|
+
if (url === '/api/project-stats' && method === 'GET') {
|
|
991
|
+
try {
|
|
992
|
+
if (!_projectName) {
|
|
993
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
994
|
+
res.end(JSON.stringify({ error: 'No project name' }));
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const statsFile = join(LOG_DIR, _projectName, `${_projectName}.json`);
|
|
998
|
+
if (!existsSync(statsFile)) {
|
|
999
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1000
|
+
res.end(JSON.stringify({ error: 'Stats file not found' }));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const stats = readFileSync(statsFile, 'utf-8');
|
|
1004
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1005
|
+
res.end(stats);
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1008
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1009
|
+
}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// 所有项目统计数据
|
|
1014
|
+
if (url === '/api/all-project-stats' && method === 'GET') {
|
|
1015
|
+
try {
|
|
1016
|
+
const allStats = {};
|
|
1017
|
+
if (existsSync(LOG_DIR)) {
|
|
1018
|
+
const entries = readdirSync(LOG_DIR, { withFileTypes: true });
|
|
1019
|
+
for (const entry of entries) {
|
|
1020
|
+
if (!entry.isDirectory()) continue;
|
|
1021
|
+
const project = entry.name;
|
|
1022
|
+
const statsFile = join(LOG_DIR, project, `${project}.json`);
|
|
1023
|
+
if (existsSync(statsFile)) {
|
|
1024
|
+
try {
|
|
1025
|
+
allStats[project] = JSON.parse(readFileSync(statsFile, 'utf-8'));
|
|
1026
|
+
} catch { }
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1031
|
+
res.end(JSON.stringify(allStats));
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1034
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1035
|
+
}
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// 刷新统计:强制重新扫描所有项目日志,等待完成后再响应
|
|
1040
|
+
if (url === '/api/refresh-stats' && method === 'POST') {
|
|
1041
|
+
try {
|
|
1042
|
+
if (!statsWorker) startStatsWorker();
|
|
1043
|
+
if (statsWorker) {
|
|
1044
|
+
const timeout = setTimeout(() => {
|
|
1045
|
+
statsWorker?.removeListener('message', onDone);
|
|
1046
|
+
res.writeHead(504, { 'Content-Type': 'application/json' });
|
|
1047
|
+
res.end(JSON.stringify({ error: 'Stats refresh timed out' }));
|
|
1048
|
+
}, 30000);
|
|
1049
|
+
const onDone = (m) => {
|
|
1050
|
+
if (m.type === 'scan-all-done') {
|
|
1051
|
+
clearTimeout(timeout);
|
|
1052
|
+
statsWorker?.removeListener('message', onDone);
|
|
1053
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1054
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
statsWorker.on('message', onDone);
|
|
1058
|
+
statsWorker.postMessage({ type: 'scan-all', logDir: LOG_DIR });
|
|
1059
|
+
} else {
|
|
1060
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1061
|
+
res.end(JSON.stringify({ error: 'Stats worker not available' }));
|
|
1062
|
+
}
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1065
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1066
|
+
}
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Codex settings.json(启动时读取,不 watch)
|
|
1071
|
+
if (url === '/api/codex-settings' && method === 'GET') {
|
|
1072
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1073
|
+
const fileEnv = codexSettings.env || {};
|
|
1074
|
+
// 与 Codex Code 保持一致:settings.json env 优先,fallback 到 process.env
|
|
1075
|
+
const env = { ...fileEnv };
|
|
1076
|
+
if (!env.CODEX_EXPERIMENTAL_AGENT_TEAMS && process.env.CODEX_EXPERIMENTAL_AGENT_TEAMS) {
|
|
1077
|
+
env.CODEX_EXPERIMENTAL_AGENT_TEAMS = process.env.CODEX_EXPERIMENTAL_AGENT_TEAMS;
|
|
1078
|
+
}
|
|
1079
|
+
res.end(JSON.stringify({ env, model: codexSettings.model || null, showThinkingSummaries: codexSettings.showThinkingSummaries || false, codexAvailable: process.env.CXV_CODEX_MISSING !== '1' }));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (url === '/api/codex-settings' && method === 'POST') {
|
|
1084
|
+
let body = '';
|
|
1085
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1086
|
+
req.on('end', () => {
|
|
1087
|
+
try {
|
|
1088
|
+
const incoming = JSON.parse(body);
|
|
1089
|
+
const settingsPath = join(homedir(), '.codex', 'settings.json');
|
|
1090
|
+
let settings = {};
|
|
1091
|
+
try { if (existsSync(settingsPath)) settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { }
|
|
1092
|
+
Object.assign(settings, incoming);
|
|
1093
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
1094
|
+
Object.assign(codexSettings, incoming);
|
|
1095
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1096
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1097
|
+
} catch {
|
|
1098
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1099
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Proxy profile 热切换
|
|
1106
|
+
if (url === '/api/proxy-profiles' && method === 'GET') {
|
|
1107
|
+
try {
|
|
1108
|
+
const data = existsSync(PROFILE_PATH) ? JSON.parse(readFileSync(PROFILE_PATH, 'utf-8')) : _defaultProxyProfiles;
|
|
1109
|
+
const masked = _maskProfiles(data);
|
|
1110
|
+
if (_defaultConfig) masked.defaultConfig = { ..._defaultConfig, apiKey: _defaultConfig.apiKey ? _maskApiKey(_defaultConfig.apiKey) : null };
|
|
1111
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1112
|
+
res.end(JSON.stringify(masked));
|
|
1113
|
+
} catch {
|
|
1114
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1115
|
+
res.end(JSON.stringify(_defaultProxyProfiles));
|
|
1116
|
+
}
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (url === '/api/proxy-profiles' && method === 'POST') {
|
|
1121
|
+
let body = '';
|
|
1122
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1123
|
+
req.on('end', () => {
|
|
1124
|
+
try {
|
|
1125
|
+
const incoming = JSON.parse(body);
|
|
1126
|
+
if (!incoming || typeof incoming !== 'object' || !Array.isArray(incoming.profiles)) {
|
|
1127
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1128
|
+
res.end(JSON.stringify({ error: 'Invalid profile data: profiles must be an array' }));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
// 确保 max profile 始终存在
|
|
1132
|
+
if (!incoming.profiles.some(p => p.id === 'max')) {
|
|
1133
|
+
incoming.profiles = [{ id: 'max', name: 'Default' }, ...(incoming.profiles || [])];
|
|
1134
|
+
}
|
|
1135
|
+
// 如果 apiKey 是 mask 值(未修改),从磁盘读取原始值保留
|
|
1136
|
+
let existing = {};
|
|
1137
|
+
try { if (existsSync(PROFILE_PATH)) existing = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8')); } catch { }
|
|
1138
|
+
const existingMap = {};
|
|
1139
|
+
if (existing.profiles) existing.profiles.forEach(p => { if (p.apiKey) existingMap[p.id] = p.apiKey; });
|
|
1140
|
+
for (const p of incoming.profiles) {
|
|
1141
|
+
if (p.apiKey && _isMasked(p.apiKey) && existingMap[p.id]) {
|
|
1142
|
+
p.apiKey = existingMap[p.id];
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
const dir = dirname(PROFILE_PATH);
|
|
1146
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1147
|
+
writeFileSync(PROFILE_PATH, JSON.stringify(incoming, null, 2), { mode: 0o600 });
|
|
1148
|
+
_loadProxyProfile();
|
|
1149
|
+
// SSE 广播给所有 viewer 客户端(mask apiKey)
|
|
1150
|
+
const activeProfile = incoming.profiles?.find(p => p.id === incoming.active) || null;
|
|
1151
|
+
const maskedProfile = activeProfile?.apiKey ? { ...activeProfile, apiKey: _maskApiKey(activeProfile.apiKey) } : activeProfile;
|
|
1152
|
+
sendEventToClients(clients, 'proxy_profile', { active: incoming.active, profile: maskedProfile });
|
|
1153
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1154
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1155
|
+
} catch {
|
|
1156
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1157
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// macOS 用户头像和显示名
|
|
1164
|
+
if (url === '/api/user-profile' && method === 'GET') {
|
|
1165
|
+
const profile = await getUserProfile();
|
|
1166
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1167
|
+
res.end(JSON.stringify(profile));
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// 文件浏览器 API(CLI 模式下项目目录浏览)
|
|
1172
|
+
if (url === '/api/files' && method === 'GET') {
|
|
1173
|
+
const reqPath = parsedUrl.searchParams.get('path') || '.';
|
|
1174
|
+
// 安全校验:拒绝绝对路径和 .. 路径穿越
|
|
1175
|
+
if (reqPath.startsWith('/') || reqPath.includes('..')) {
|
|
1176
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1177
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1181
|
+
const targetDir = join(cwd, reqPath);
|
|
1182
|
+
try {
|
|
1183
|
+
const entries = readdirSync(targetDir, { withFileTypes: true });
|
|
1184
|
+
const items = entries
|
|
1185
|
+
.filter(e => !IGNORED_PATTERNS.has(e.name))
|
|
1186
|
+
.map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' }))
|
|
1187
|
+
.sort((a, b) => {
|
|
1188
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
1189
|
+
return a.name.localeCompare(b.name);
|
|
1190
|
+
});
|
|
1191
|
+
// 使用 git check-ignore 批量检测被 .gitignore 忽略的文件
|
|
1192
|
+
let gitIgnoredSet = new Set();
|
|
1193
|
+
try {
|
|
1194
|
+
const names = items.map(i => {
|
|
1195
|
+
const rel = reqPath === '.' ? i.name : `${reqPath}/${i.name}`;
|
|
1196
|
+
return i.type === 'directory' ? `${rel}/` : rel;
|
|
1197
|
+
});
|
|
1198
|
+
if (names.length > 0) {
|
|
1199
|
+
const result = await execWithStdin('git', ['check-ignore', '--stdin'], names.join('\n'), {
|
|
1200
|
+
cwd,
|
|
1201
|
+
timeout: 3000,
|
|
1202
|
+
});
|
|
1203
|
+
result.split('\n').filter(Boolean).forEach(line => {
|
|
1204
|
+
const name = line.endsWith('/') ? line.slice(0, -1) : line;
|
|
1205
|
+
const baseName = name.includes('/') ? name.split('/').pop() : name;
|
|
1206
|
+
gitIgnoredSet.add(baseName);
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
} catch { /* git 未安装或非 git 仓库,忽略 */ }
|
|
1210
|
+
const result = items.map(i => gitIgnoredSet.has(i.name) ? { ...i, gitIgnored: true } : i);
|
|
1211
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1212
|
+
res.end(JSON.stringify(result));
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1215
|
+
res.end(JSON.stringify({ error: 'Directory not found' }));
|
|
1216
|
+
}
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// 文件重命名 API
|
|
1221
|
+
if (url === '/api/rename-file' && method === 'POST') {
|
|
1222
|
+
let body = '';
|
|
1223
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1224
|
+
req.on('end', () => {
|
|
1225
|
+
let parsed;
|
|
1226
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1227
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1228
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
const { oldPath, newName } = parsed;
|
|
1233
|
+
if (!oldPath || !newName) {
|
|
1234
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1235
|
+
res.end(JSON.stringify({ error: 'Missing oldPath or newName' }));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
// 安全校验
|
|
1239
|
+
if (oldPath.startsWith('/') || oldPath.includes('..') || newName.includes('/') || newName.includes('\\') || newName.includes('..')) {
|
|
1240
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1241
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1245
|
+
const oldFullPath = join(cwd, oldPath);
|
|
1246
|
+
const parentDir = dirname(oldFullPath);
|
|
1247
|
+
const newFullPath = join(parentDir, newName);
|
|
1248
|
+
// 检查源文件存在
|
|
1249
|
+
if (!existsSync(oldFullPath)) {
|
|
1250
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1251
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
// 检查目标是否已存在
|
|
1255
|
+
if (existsSync(newFullPath)) {
|
|
1256
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1257
|
+
res.end(JSON.stringify({ error: 'Target already exists' }));
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
renameSync(oldFullPath, newFullPath);
|
|
1261
|
+
const newRelPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName : newName;
|
|
1262
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1263
|
+
res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1266
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// 文件移动 API
|
|
1273
|
+
if (url === '/api/move-file' && method === 'POST') {
|
|
1274
|
+
let body = '';
|
|
1275
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1276
|
+
req.on('end', () => {
|
|
1277
|
+
let parsed;
|
|
1278
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1279
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1280
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
try {
|
|
1284
|
+
const { fromPath, toDir } = parsed;
|
|
1285
|
+
if (!fromPath || !toDir) {
|
|
1286
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1287
|
+
res.end(JSON.stringify({ error: 'Missing fromPath or toDir' }));
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
// 安全校验
|
|
1291
|
+
if (fromPath.startsWith('/') || fromPath.includes('..') || toDir.startsWith('/') || toDir.includes('..')) {
|
|
1292
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1293
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1297
|
+
const oldFullPath = join(cwd, fromPath);
|
|
1298
|
+
const toDirFull = join(cwd, toDir);
|
|
1299
|
+
// 检查源文件/目录存在
|
|
1300
|
+
if (!existsSync(oldFullPath)) {
|
|
1301
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1302
|
+
res.end(JSON.stringify({ error: 'Source not found' }));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
// 检查目标目录存在且是目录
|
|
1306
|
+
if (!existsSync(toDirFull) || !statSync(toDirFull).isDirectory()) {
|
|
1307
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1308
|
+
res.end(JSON.stringify({ error: 'Target directory not found' }));
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
// 不能把目录移到自身或其子目录下
|
|
1312
|
+
if (statSync(oldFullPath).isDirectory()) {
|
|
1313
|
+
const srcResolved = resolve(oldFullPath);
|
|
1314
|
+
const destResolved = resolve(toDirFull);
|
|
1315
|
+
if (destResolved === srcResolved || destResolved.startsWith(srcResolved + '/')) {
|
|
1316
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1317
|
+
res.end(JSON.stringify({ error: 'Cannot move directory into itself' }));
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const name = basename(fromPath);
|
|
1322
|
+
const newFullPath = join(toDirFull, name);
|
|
1323
|
+
// 检查目标位置不存在同名文件
|
|
1324
|
+
if (existsSync(newFullPath)) {
|
|
1325
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1326
|
+
res.end(JSON.stringify({ error: 'Target already exists' }));
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
try {
|
|
1330
|
+
renameSync(oldFullPath, newFullPath);
|
|
1331
|
+
} catch (mvErr) {
|
|
1332
|
+
if (mvErr.code === 'EXDEV') {
|
|
1333
|
+
// 跨文件系统:fallback to copy + delete
|
|
1334
|
+
if (statSync(oldFullPath).isDirectory()) {
|
|
1335
|
+
cpSync(oldFullPath, newFullPath, { recursive: true });
|
|
1336
|
+
rmSync(oldFullPath, { recursive: true, force: true });
|
|
1337
|
+
} else {
|
|
1338
|
+
copyFileSync(oldFullPath, newFullPath);
|
|
1339
|
+
unlinkSync(oldFullPath);
|
|
1340
|
+
}
|
|
1341
|
+
} else if (mvErr.code === 'EEXIST') {
|
|
1342
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1343
|
+
res.end(JSON.stringify({ error: 'Target already exists' }));
|
|
1344
|
+
return;
|
|
1345
|
+
} else {
|
|
1346
|
+
throw mvErr;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
const newRelPath = toDir.endsWith('/') ? toDir + name : toDir + '/' + name;
|
|
1350
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1351
|
+
res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
console.error('move-file error:', err);
|
|
1354
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1355
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// 删除文件 API
|
|
1362
|
+
if (url === '/api/delete-file' && method === 'POST') {
|
|
1363
|
+
let body = '';
|
|
1364
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1365
|
+
req.on('end', () => {
|
|
1366
|
+
let parsed;
|
|
1367
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1368
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1369
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
const { path: filePath } = parsed;
|
|
1374
|
+
if (!filePath) {
|
|
1375
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1376
|
+
res.end(JSON.stringify({ error: 'Missing path' }));
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (filePath.startsWith('/') || filePath.includes('..')) {
|
|
1380
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1381
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1385
|
+
const fullPath = join(cwd, filePath);
|
|
1386
|
+
if (!existsSync(fullPath)) {
|
|
1387
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1388
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const realFull = realpathSync(fullPath);
|
|
1392
|
+
const realCwd = realpathSync(cwd);
|
|
1393
|
+
if (!realFull.startsWith(realCwd + '/')) {
|
|
1394
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1395
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const stat = statSync(fullPath);
|
|
1399
|
+
if (stat.isDirectory()) {
|
|
1400
|
+
const protectedDirs = new Set(['node_modules', '.git', '.svn', '.hg']);
|
|
1401
|
+
if (filePath.split('/').some(part => protectedDirs.has(part))) {
|
|
1402
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1403
|
+
res.end(JSON.stringify({ error: 'Cannot delete protected directory' }));
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
1407
|
+
} else if (stat.isFile()) {
|
|
1408
|
+
unlinkSync(fullPath);
|
|
1409
|
+
} else {
|
|
1410
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1411
|
+
res.end(JSON.stringify({ error: 'Unsupported path type' }));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1415
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1418
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// 在系统文件管理器中显示文件
|
|
1425
|
+
if (url === '/api/reveal-file' && method === 'POST') {
|
|
1426
|
+
let body = '';
|
|
1427
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1428
|
+
req.on('end', () => {
|
|
1429
|
+
let parsed;
|
|
1430
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1431
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1432
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
try {
|
|
1436
|
+
const { path: filePath } = parsed;
|
|
1437
|
+
if (!filePath) {
|
|
1438
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1439
|
+
res.end(JSON.stringify({ error: 'Missing path' }));
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
if (filePath.startsWith('/') || filePath.includes('..')) {
|
|
1443
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1444
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1448
|
+
const fullPath = join(cwd, filePath);
|
|
1449
|
+
if (!existsSync(fullPath)) {
|
|
1450
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1451
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const realFull = realpathSync(fullPath);
|
|
1455
|
+
const realCwd = realpathSync(cwd);
|
|
1456
|
+
if (!realFull.startsWith(realCwd + '/')) {
|
|
1457
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1458
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
const plat = process.platform;
|
|
1462
|
+
if (plat === 'darwin') {
|
|
1463
|
+
execFile('open', ['-R', fullPath], () => {});
|
|
1464
|
+
} else if (plat === 'win32') {
|
|
1465
|
+
spawn('explorer', ['/select,', fullPath], { shell: false });
|
|
1466
|
+
} else {
|
|
1467
|
+
execFile('xdg-open', [dirname(fullPath)], () => {});
|
|
1468
|
+
}
|
|
1469
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1470
|
+
res.end(JSON.stringify({ ok: true, fullPath }));
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1473
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// 用系统默认应用打开文件
|
|
1480
|
+
if (url === '/api/open-file' && method === 'POST') {
|
|
1481
|
+
let body = '';
|
|
1482
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1483
|
+
req.on('end', () => {
|
|
1484
|
+
let parsed;
|
|
1485
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1486
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1487
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
try {
|
|
1491
|
+
const { path: filePath } = parsed;
|
|
1492
|
+
if (!filePath) {
|
|
1493
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1494
|
+
res.end(JSON.stringify({ error: 'Missing path' }));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (filePath.startsWith('/') || filePath.includes('..')) {
|
|
1498
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1499
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1503
|
+
const fullPath = join(cwd, filePath);
|
|
1504
|
+
if (!existsSync(fullPath)) {
|
|
1505
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1506
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
const realFull = realpathSync(fullPath);
|
|
1510
|
+
const realCwd = realpathSync(cwd);
|
|
1511
|
+
if (!realFull.startsWith(realCwd + '/')) {
|
|
1512
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1513
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
const plat = process.platform;
|
|
1517
|
+
if (plat === 'darwin') {
|
|
1518
|
+
execFile('open', [fullPath], () => {});
|
|
1519
|
+
} else if (plat === 'win32') {
|
|
1520
|
+
execFile('cmd.exe', ['/c', 'start', '', fullPath], () => {});
|
|
1521
|
+
} else {
|
|
1522
|
+
execFile('xdg-open', [fullPath], () => {});
|
|
1523
|
+
}
|
|
1524
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1525
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1528
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// 解析相对路径为绝对路径(不触发任何副作用)
|
|
1535
|
+
if (url === '/api/resolve-path' && method === 'POST') {
|
|
1536
|
+
let body = '';
|
|
1537
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1538
|
+
req.on('end', () => {
|
|
1539
|
+
let parsed;
|
|
1540
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1541
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1542
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
try {
|
|
1546
|
+
const relPath = parsed.path || '';
|
|
1547
|
+
if (relPath.startsWith('/') || relPath.includes('..')) {
|
|
1548
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1549
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1553
|
+
const fullPath = relPath ? join(cwd, relPath) : cwd;
|
|
1554
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1555
|
+
res.end(JSON.stringify({ ok: true, fullPath }));
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1558
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// 在指定目录下新建空文件
|
|
1565
|
+
if (url === '/api/create-file' && method === 'POST') {
|
|
1566
|
+
let body = '';
|
|
1567
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1568
|
+
req.on('end', () => {
|
|
1569
|
+
let parsed;
|
|
1570
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1571
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1572
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
try {
|
|
1576
|
+
const { dirPath, name } = parsed;
|
|
1577
|
+
if (!name) {
|
|
1578
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1579
|
+
res.end(JSON.stringify({ error: 'Missing name' }));
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..') || /[\x00-\x1f]/.test(name)) {
|
|
1583
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1584
|
+
res.end(JSON.stringify({ error: 'Invalid file name' }));
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const relDir = dirPath || '';
|
|
1588
|
+
if (relDir.startsWith('/') || relDir.includes('..')) {
|
|
1589
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1590
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1594
|
+
const fullDirPath = relDir ? join(cwd, relDir) : cwd;
|
|
1595
|
+
if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
|
|
1596
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1597
|
+
res.end(JSON.stringify({ error: 'Directory not found' }));
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const realDir = realpathSync(fullDirPath);
|
|
1601
|
+
const realCwd = realpathSync(cwd);
|
|
1602
|
+
if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
|
|
1603
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1604
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const fullPath = join(fullDirPath, name);
|
|
1608
|
+
if (existsSync(fullPath)) {
|
|
1609
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1610
|
+
res.end(JSON.stringify({ error: 'File already exists' }));
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
writeFileSync(fullPath, '');
|
|
1614
|
+
const relPath = relDir ? `${relDir}/${name}` : name;
|
|
1615
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1616
|
+
res.end(JSON.stringify({ ok: true, path: relPath }));
|
|
1617
|
+
} catch (err) {
|
|
1618
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1619
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// 在指定目录下打开系统终端
|
|
1626
|
+
if (url === '/api/open-terminal' && method === 'POST') {
|
|
1627
|
+
let body = '';
|
|
1628
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1629
|
+
req.on('end', () => {
|
|
1630
|
+
let parsed;
|
|
1631
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1632
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1633
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
try {
|
|
1637
|
+
const relDir = (parsed.path || '');
|
|
1638
|
+
if (relDir.startsWith('/') || relDir.includes('..')) {
|
|
1639
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1640
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1644
|
+
const fullDir = relDir ? join(cwd, relDir) : cwd;
|
|
1645
|
+
if (!existsSync(fullDir) || !statSync(fullDir).isDirectory()) {
|
|
1646
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1647
|
+
res.end(JSON.stringify({ error: 'Directory not found' }));
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
const realDir = realpathSync(fullDir);
|
|
1651
|
+
const realCwd = realpathSync(cwd);
|
|
1652
|
+
if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
|
|
1653
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1654
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const plat = process.platform;
|
|
1658
|
+
if (plat === 'darwin') {
|
|
1659
|
+
spawn('open', ['-a', 'Terminal', fullDir], { stdio: 'ignore', detached: true }).unref();
|
|
1660
|
+
} else if (plat === 'win32') {
|
|
1661
|
+
spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: fullDir, stdio: 'ignore', detached: true }).unref();
|
|
1662
|
+
} else {
|
|
1663
|
+
// Linux: try common terminal emulators
|
|
1664
|
+
const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
|
|
1665
|
+
let launched = false;
|
|
1666
|
+
for (const term of terminals) {
|
|
1667
|
+
try {
|
|
1668
|
+
if (term === 'gnome-terminal') {
|
|
1669
|
+
spawn(term, ['--working-directory=' + fullDir], { stdio: 'ignore', detached: true }).unref();
|
|
1670
|
+
} else if (term === 'konsole') {
|
|
1671
|
+
spawn(term, ['--workdir', fullDir], { stdio: 'ignore', detached: true }).unref();
|
|
1672
|
+
} else {
|
|
1673
|
+
spawn(term, [], { cwd: fullDir, stdio: 'ignore', detached: true }).unref();
|
|
1674
|
+
}
|
|
1675
|
+
launched = true;
|
|
1676
|
+
break;
|
|
1677
|
+
} catch { continue; }
|
|
1678
|
+
}
|
|
1679
|
+
if (!launched) {
|
|
1680
|
+
spawn('xdg-open', [fullDir], { stdio: 'ignore', detached: true }).unref();
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1684
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1685
|
+
} catch (err) {
|
|
1686
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1687
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// 在指定目录下新建空文件夹
|
|
1694
|
+
if (url === '/api/create-dir' && method === 'POST') {
|
|
1695
|
+
let body = '';
|
|
1696
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1697
|
+
req.on('end', () => {
|
|
1698
|
+
let parsed;
|
|
1699
|
+
try { parsed = JSON.parse(body); } catch {
|
|
1700
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1701
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
try {
|
|
1705
|
+
const { dirPath, name } = parsed;
|
|
1706
|
+
if (!name) {
|
|
1707
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1708
|
+
res.end(JSON.stringify({ error: 'Missing name' }));
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..') || /[\x00-\x1f]/.test(name)) {
|
|
1712
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1713
|
+
res.end(JSON.stringify({ error: 'Invalid folder name' }));
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const relDir = dirPath || '';
|
|
1717
|
+
if (relDir.startsWith('/') || relDir.includes('..')) {
|
|
1718
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1719
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1723
|
+
const fullDirPath = relDir ? join(cwd, relDir) : cwd;
|
|
1724
|
+
if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
|
|
1725
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1726
|
+
res.end(JSON.stringify({ error: 'Directory not found' }));
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const realDir = realpathSync(fullDirPath);
|
|
1730
|
+
const realCwd = realpathSync(cwd);
|
|
1731
|
+
if (realDir !== realCwd && !realDir.startsWith(realCwd + '/')) {
|
|
1732
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1733
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const fullPath = join(fullDirPath, name);
|
|
1737
|
+
if (existsSync(fullPath)) {
|
|
1738
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1739
|
+
res.end(JSON.stringify({ error: 'Already exists' }));
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
mkdirSync(fullPath);
|
|
1743
|
+
const relPath = relDir ? `${relDir}/${name}` : name;
|
|
1744
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1745
|
+
res.end(JSON.stringify({ ok: true, path: relPath }));
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1748
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// === Editor session API (for $EDITOR intercept) ===
|
|
1755
|
+
|
|
1756
|
+
if (url === '/api/open-log-dir' && method === 'POST') {
|
|
1757
|
+
const dir = LOG_FILE ? dirname(LOG_FILE) : LOG_DIR;
|
|
1758
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
1759
|
+
execFile(cmd, [dir], () => {});
|
|
1760
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1761
|
+
res.end(JSON.stringify({ ok: true, dir }));
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
if (url === '/api/open-profile-dir' && method === 'POST') {
|
|
1766
|
+
const dir = dirname(PROFILE_PATH);
|
|
1767
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1768
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
1769
|
+
execFile(cmd, [dir], () => {});
|
|
1770
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1771
|
+
res.end(JSON.stringify({ ok: true, dir }));
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (url === '/api/open-project-dir' && method === 'POST') {
|
|
1776
|
+
const dir = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
1777
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
1778
|
+
execFile(cmd, [dir], () => {});
|
|
1779
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1780
|
+
res.end(JSON.stringify({ ok: true, dir }));
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
if (url === '/api/editor-open' && method === 'POST') {
|
|
1785
|
+
let body = '';
|
|
1786
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1787
|
+
req.on('end', () => {
|
|
1788
|
+
try {
|
|
1789
|
+
const { sessionId, filePath } = JSON.parse(body);
|
|
1790
|
+
if (!sessionId || !filePath) {
|
|
1791
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1792
|
+
res.end(JSON.stringify({ error: 'Missing sessionId or filePath' }));
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
editorSessions.set(sessionId, { filePath, done: false, createdAt: Date.now() });
|
|
1796
|
+
// Broadcast to all terminal WebSocket clients
|
|
1797
|
+
if (terminalWss) {
|
|
1798
|
+
const msg = JSON.stringify({ type: 'editor-open', sessionId, filePath });
|
|
1799
|
+
terminalWss.clients.forEach(client => {
|
|
1800
|
+
if (client.readyState === 1) {
|
|
1801
|
+
try { client.send(msg); } catch {}
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1806
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1807
|
+
} catch {
|
|
1808
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1809
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (url.startsWith('/api/editor-status') && method === 'GET') {
|
|
1816
|
+
const id = parsedUrl.searchParams.get('id');
|
|
1817
|
+
if (!id) {
|
|
1818
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1819
|
+
res.end(JSON.stringify({ error: 'Missing id' }));
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const session = editorSessions.get(id);
|
|
1823
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1824
|
+
res.end(JSON.stringify({ done: session ? session.done : true }));
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
if (url === '/api/editor-done' && method === 'POST') {
|
|
1829
|
+
let body = '';
|
|
1830
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
1831
|
+
req.on('end', () => {
|
|
1832
|
+
try {
|
|
1833
|
+
const { sessionId } = JSON.parse(body);
|
|
1834
|
+
if (!sessionId) {
|
|
1835
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1836
|
+
res.end(JSON.stringify({ error: 'Missing sessionId' }));
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
const session = editorSessions.get(sessionId);
|
|
1840
|
+
if (session) {
|
|
1841
|
+
session.done = true;
|
|
1842
|
+
}
|
|
1843
|
+
// Clean up after a short delay to allow the polling to pick it up
|
|
1844
|
+
setTimeout(() => editorSessions.delete(sessionId), 5000);
|
|
1845
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1846
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1847
|
+
} catch {
|
|
1848
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1849
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// Ask hook bridge: long-poll endpoint for PreToolUse AskUserQuestion hook
|
|
1856
|
+
if (url === '/api/ask-hook' && method === 'POST') {
|
|
1857
|
+
let body = '';
|
|
1858
|
+
req.on('data', (chunk) => {
|
|
1859
|
+
body += chunk;
|
|
1860
|
+
if (body.length > 1000000) { // 1MB limit (questions may contain large previews)
|
|
1861
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
1862
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
req.on('end', () => {
|
|
1867
|
+
try {
|
|
1868
|
+
const { questions } = JSON.parse(body);
|
|
1869
|
+
if (!Array.isArray(questions) || questions.length === 0) {
|
|
1870
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1871
|
+
res.end(JSON.stringify({ error: 'Missing questions' }));
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// Cancel any previous pending hook request
|
|
1876
|
+
if (pendingAskHook) {
|
|
1877
|
+
try {
|
|
1878
|
+
if (!pendingAskHook.res.headersSent) {
|
|
1879
|
+
pendingAskHook.res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1880
|
+
pendingAskHook.res.end(JSON.stringify({ error: 'Superseded' }));
|
|
1881
|
+
}
|
|
1882
|
+
} catch {}
|
|
1883
|
+
clearTimeout(pendingAskHook.timer);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const HOOK_TIMEOUT = HOOK_TIMEOUT_MS;
|
|
1887
|
+
const timer = setTimeout(() => {
|
|
1888
|
+
if (pendingAskHook && pendingAskHook.res === res) {
|
|
1889
|
+
pendingAskHook = null;
|
|
1890
|
+
try {
|
|
1891
|
+
if (!res.headersSent) {
|
|
1892
|
+
res.writeHead(408, { 'Content-Type': 'application/json' });
|
|
1893
|
+
res.end(JSON.stringify({ error: 'Timeout' }));
|
|
1894
|
+
}
|
|
1895
|
+
} catch {}
|
|
1896
|
+
// Broadcast timeout to clients
|
|
1897
|
+
if (terminalWss) {
|
|
1898
|
+
const tmsg = JSON.stringify({ type: 'ask-hook-timeout' });
|
|
1899
|
+
terminalWss.clients.forEach((c) => {
|
|
1900
|
+
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}, HOOK_TIMEOUT);
|
|
1905
|
+
|
|
1906
|
+
pendingAskHook = { questions, res, timer, createdAt: Date.now() };
|
|
1907
|
+
|
|
1908
|
+
// Broadcast to all terminal WS clients
|
|
1909
|
+
if (terminalWss) {
|
|
1910
|
+
const pmsg = JSON.stringify({ type: 'ask-hook-pending', questions });
|
|
1911
|
+
terminalWss.clients.forEach((client) => {
|
|
1912
|
+
if (client.readyState === 1) {
|
|
1913
|
+
try { client.send(pmsg); } catch {}
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// Handle ask-bridge.js disconnection (use res instead of req — Node.js v24+ fires req 'close' immediately after body is read)
|
|
1919
|
+
res.on('close', () => {
|
|
1920
|
+
if (pendingAskHook && pendingAskHook.res === res) {
|
|
1921
|
+
clearTimeout(pendingAskHook.timer);
|
|
1922
|
+
pendingAskHook = null;
|
|
1923
|
+
if (terminalWss) {
|
|
1924
|
+
const tmsg = JSON.stringify({ type: 'ask-hook-timeout' });
|
|
1925
|
+
terminalWss.clients.forEach((c) => {
|
|
1926
|
+
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
} catch {
|
|
1932
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1933
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Permission hook bridge: receive tool permission request from perm-bridge.js, long-poll for user decision
|
|
1940
|
+
if (url === '/api/perm-hook' && method === 'POST') {
|
|
1941
|
+
let body = '';
|
|
1942
|
+
req.on('data', (chunk) => {
|
|
1943
|
+
body += chunk;
|
|
1944
|
+
if (body.length > 1000000) {
|
|
1945
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
1946
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1950
|
+
req.on('end', () => {
|
|
1951
|
+
try {
|
|
1952
|
+
const { toolName, input } = JSON.parse(body);
|
|
1953
|
+
if (!toolName) {
|
|
1954
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1955
|
+
res.end(JSON.stringify({ error: 'Missing toolName' }));
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Cancel any previous pending permission request
|
|
1960
|
+
if (pendingPermHook) {
|
|
1961
|
+
try {
|
|
1962
|
+
if (!pendingPermHook.res.headersSent) {
|
|
1963
|
+
pendingPermHook.res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1964
|
+
pendingPermHook.res.end(JSON.stringify({ error: 'Superseded' }));
|
|
1965
|
+
}
|
|
1966
|
+
} catch {}
|
|
1967
|
+
clearTimeout(pendingPermHook.timer);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const HOOK_TIMEOUT = HOOK_TIMEOUT_MS;
|
|
1971
|
+
const id = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1972
|
+
const timer = setTimeout(() => {
|
|
1973
|
+
if (pendingPermHook && pendingPermHook.id === id) {
|
|
1974
|
+
pendingPermHook = null;
|
|
1975
|
+
try {
|
|
1976
|
+
if (!res.headersSent) {
|
|
1977
|
+
res.writeHead(408, { 'Content-Type': 'application/json' });
|
|
1978
|
+
res.end(JSON.stringify({ error: 'Timeout' }));
|
|
1979
|
+
}
|
|
1980
|
+
} catch {}
|
|
1981
|
+
if (terminalWss) {
|
|
1982
|
+
const tmsg = JSON.stringify({ type: 'perm-hook-timeout' });
|
|
1983
|
+
terminalWss.clients.forEach((c) => {
|
|
1984
|
+
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}, HOOK_TIMEOUT);
|
|
1989
|
+
|
|
1990
|
+
pendingPermHook = { id, toolName, input, res, timer, createdAt: Date.now() };
|
|
1991
|
+
|
|
1992
|
+
// Broadcast to all terminal WS clients
|
|
1993
|
+
if (terminalWss) {
|
|
1994
|
+
const pmsg = JSON.stringify({ type: 'perm-hook-pending', id, toolName, input });
|
|
1995
|
+
terminalWss.clients.forEach((client) => {
|
|
1996
|
+
if (client.readyState === 1) {
|
|
1997
|
+
try { client.send(pmsg); } catch {}
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// Handle perm-bridge.js disconnection
|
|
2003
|
+
res.on('close', () => {
|
|
2004
|
+
if (pendingPermHook && pendingPermHook.id === id) {
|
|
2005
|
+
clearTimeout(pendingPermHook.timer);
|
|
2006
|
+
pendingPermHook = null;
|
|
2007
|
+
if (terminalWss) {
|
|
2008
|
+
const tmsg = JSON.stringify({ type: 'perm-hook-timeout' });
|
|
2009
|
+
terminalWss.clients.forEach((c) => {
|
|
2010
|
+
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
} catch {
|
|
2016
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2017
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// 读取文件内容 API
|
|
2024
|
+
if (url === '/api/file-content' && method === 'GET') {
|
|
2025
|
+
const reqPath = parsedUrl.searchParams.get('path');
|
|
2026
|
+
const isEditorSession = parsedUrl.searchParams.get('editorSession') === 'true';
|
|
2027
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
2028
|
+
try {
|
|
2029
|
+
const result = readFileContent(cwd, reqPath, isEditorSession);
|
|
2030
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2031
|
+
res.end(JSON.stringify(result));
|
|
2032
|
+
} catch (err) {
|
|
2033
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
2034
|
+
const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
|
|
2035
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2036
|
+
res.end(JSON.stringify({ error: message }));
|
|
2037
|
+
}
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// 返回文件原始二进制内容(用于图片预览等)
|
|
2042
|
+
if (url === '/api/file-raw' && (method === 'GET' || method === 'HEAD')) {
|
|
2043
|
+
const reqPath = parsedUrl.searchParams.get('path');
|
|
2044
|
+
const isEditorSession = parsedUrl.searchParams.get('editorSession') === 'true';
|
|
2045
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
2046
|
+
try {
|
|
2047
|
+
// 上传图片路径(/tmp/cx-viewer-uploads/ 或持久化目录)直接使用,跳过项目目录安全检查
|
|
2048
|
+
const uploadPrefix = UPLOAD_DIR + '/';
|
|
2049
|
+
const pName = _projectName || 'default';
|
|
2050
|
+
const persistPrefix = join(homedir(), '.codex', 'cx-viewer', pName, 'images') + '/';
|
|
2051
|
+
let targetFile;
|
|
2052
|
+
if (reqPath && reqPath.startsWith(uploadPrefix)) {
|
|
2053
|
+
targetFile = resolve(reqPath);
|
|
2054
|
+
// 路径穿越防护:resolve 后必须仍在 upload 目录内
|
|
2055
|
+
if (!targetFile.startsWith(uploadPrefix)) {
|
|
2056
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2057
|
+
res.end(JSON.stringify({ error: 'Path traversal denied' }));
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
// /tmp 原文件不存在时,回退到持久化副本
|
|
2061
|
+
if (!existsSync(targetFile)) {
|
|
2062
|
+
const fileName = targetFile.split('/').pop();
|
|
2063
|
+
const persistFile = join(persistPrefix, fileName);
|
|
2064
|
+
if (existsSync(persistFile)) targetFile = persistFile;
|
|
2065
|
+
}
|
|
2066
|
+
} else if (reqPath && reqPath.startsWith(persistPrefix)) {
|
|
2067
|
+
targetFile = resolve(reqPath);
|
|
2068
|
+
// 路径穿越防护:resolve 后必须仍在持久化目录内
|
|
2069
|
+
if (!targetFile.startsWith(persistPrefix)) {
|
|
2070
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2071
|
+
res.end(JSON.stringify({ error: 'Path traversal denied' }));
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
} else {
|
|
2075
|
+
targetFile = resolveFilePath(cwd, reqPath, isEditorSession);
|
|
2076
|
+
}
|
|
2077
|
+
if (!existsSync(targetFile)) {
|
|
2078
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2079
|
+
res.end(JSON.stringify({ error: `File not found: ${targetFile}` }));
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
const stat = statSync(targetFile);
|
|
2083
|
+
if (!stat.isFile()) {
|
|
2084
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2085
|
+
res.end(JSON.stringify({ error: 'Not a file' }));
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
2089
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
2090
|
+
res.end(JSON.stringify({ error: 'File too large' }));
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
const extMime = {
|
|
2094
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
2095
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
2096
|
+
'.webp': 'image/webp', '.html': 'text/html', '.htm': 'text/html',
|
|
2097
|
+
};
|
|
2098
|
+
const ext = (targetFile.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
|
2099
|
+
const mime = extMime[ext] || 'application/octet-stream';
|
|
2100
|
+
const data = method === 'HEAD' ? null : readFileSync(targetFile);
|
|
2101
|
+
const size = method === 'HEAD' ? stat.size : data.length;
|
|
2102
|
+
const headers = { 'Content-Type': mime, 'Content-Length': size };
|
|
2103
|
+
// 防止用户项目中的恶意 HTML 在同源下执行脚本(XSS 防护)
|
|
2104
|
+
if (mime === 'text/html') headers['Content-Security-Policy'] = 'sandbox';
|
|
2105
|
+
res.writeHead(200, headers);
|
|
2106
|
+
res.end(data);
|
|
2107
|
+
} catch (err) {
|
|
2108
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
2109
|
+
const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
|
|
2110
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2111
|
+
res.end(JSON.stringify({ error: message }));
|
|
2112
|
+
}
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// 保存文件内容 API
|
|
2117
|
+
if (url === '/api/file-content' && method === 'POST') {
|
|
2118
|
+
const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
|
|
2119
|
+
let body = '';
|
|
2120
|
+
let overflow = false;
|
|
2121
|
+
req.on('data', chunk => {
|
|
2122
|
+
body += chunk;
|
|
2123
|
+
if (body.length > MAX_BODY) { overflow = true; req.destroy(); }
|
|
2124
|
+
});
|
|
2125
|
+
req.on('end', () => {
|
|
2126
|
+
if (overflow) {
|
|
2127
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
2128
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
try {
|
|
2132
|
+
const { path: reqPath, content, editorSession } = JSON.parse(body);
|
|
2133
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
2134
|
+
const result = writeFileContent(cwd, reqPath, content, editorSession);
|
|
2135
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2136
|
+
res.end(JSON.stringify({ ok: true, size: result.size }));
|
|
2137
|
+
} catch (err) {
|
|
2138
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
2139
|
+
const message = status === 500 ? `Cannot save file: ${err.message}` : err.message;
|
|
2140
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2141
|
+
res.end(JSON.stringify({ error: message }));
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// CLI 模式检测
|
|
2148
|
+
if (url === '/api/cli-mode' && method === 'GET') {
|
|
2149
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2150
|
+
res.end(JSON.stringify({ cliMode: isCliMode, sdkMode: isSdkMode, workspaceMode: isWorkspaceMode && !_workspaceLaunched }));
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Git 状态
|
|
2155
|
+
// 撤销单个文件的 git 变更
|
|
2156
|
+
if (url === '/api/git-restore' && method === 'POST') {
|
|
2157
|
+
let body = '';
|
|
2158
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
2159
|
+
req.on('end', async () => {
|
|
2160
|
+
let parsed;
|
|
2161
|
+
try { parsed = JSON.parse(body); } catch {
|
|
2162
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2163
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
const { path: filePath } = parsed;
|
|
2168
|
+
if (!filePath) {
|
|
2169
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2170
|
+
res.end(JSON.stringify({ error: 'Missing path' }));
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (filePath.startsWith('/') || filePath.includes('..')) {
|
|
2174
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2175
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
2179
|
+
const fullPath = join(cwd, filePath);
|
|
2180
|
+
if (existsSync(fullPath)) {
|
|
2181
|
+
const realFull = realpathSync(fullPath);
|
|
2182
|
+
const realCwd = realpathSync(cwd);
|
|
2183
|
+
if (!realFull.startsWith(realCwd + '/')) {
|
|
2184
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2185
|
+
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
// Check if file is untracked
|
|
2190
|
+
const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', filePath], { cwd, encoding: 'utf-8', timeout: 5000 });
|
|
2191
|
+
const isUntracked = statusOut.trim().startsWith('??');
|
|
2192
|
+
if (isUntracked) {
|
|
2193
|
+
await execFileAsync('git', ['clean', '-fd', '--', filePath], { cwd, timeout: 10000 });
|
|
2194
|
+
} else {
|
|
2195
|
+
await execFileAsync('git', ['checkout', '--', filePath], { cwd, timeout: 10000 });
|
|
2196
|
+
}
|
|
2197
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2198
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2201
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
if (url === '/api/git-status' && method === 'GET') {
|
|
2208
|
+
try {
|
|
2209
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
2210
|
+
const { stdout: output } = await execFileAsync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', timeout: 5000 });
|
|
2211
|
+
const lines = output.split('\n').filter(line => line.trim());
|
|
2212
|
+
const changes = lines.map(line => {
|
|
2213
|
+
const status = line.substring(0, 2).trim();
|
|
2214
|
+
let file = line.substring(3).trim();
|
|
2215
|
+
// git status --porcelain quotes paths with non-ASCII chars using octal escapes
|
|
2216
|
+
if (file.startsWith('"') && file.endsWith('"')) {
|
|
2217
|
+
file = file.slice(1, -1)
|
|
2218
|
+
.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
|
|
2219
|
+
.replace(/\\t/g, '\t').replace(/\\n/g, '\n')
|
|
2220
|
+
.replace(/\\\\/g, '\\').replace(/\\"/g, '"');
|
|
2221
|
+
file = Buffer.from(file, 'latin1').toString('utf8');
|
|
2222
|
+
}
|
|
2223
|
+
return { status, file };
|
|
2224
|
+
});
|
|
2225
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2226
|
+
res.end(JSON.stringify({ changes }));
|
|
2227
|
+
} catch (err) {
|
|
2228
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2229
|
+
res.end(JSON.stringify({ error: err.message, changes: [] }));
|
|
2230
|
+
}
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// Git diff 数据获取
|
|
2235
|
+
if (url.startsWith('/api/git-diff') && method === 'GET') {
|
|
2236
|
+
try {
|
|
2237
|
+
const cwd = process.env.CXV_PROJECT_DIR || process.cwd();
|
|
2238
|
+
const filesParam = parsedUrl.searchParams.get('files');
|
|
2239
|
+
|
|
2240
|
+
if (!filesParam) {
|
|
2241
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2242
|
+
res.end(JSON.stringify({ error: 'Missing files parameter' }));
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const files = filesParam.split(',').map(f => f.trim()).filter(Boolean);
|
|
2247
|
+
const diffs = await getGitDiffs(cwd, files);
|
|
2248
|
+
|
|
2249
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2250
|
+
res.end(JSON.stringify({ diffs }));
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2253
|
+
res.end(JSON.stringify({ error: err.message, diffs: [] }));
|
|
2254
|
+
}
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// 插件管理 API
|
|
2259
|
+
if (url === '/api/plugins' && method === 'GET') {
|
|
2260
|
+
const plugins = getPluginsInfo();
|
|
2261
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2262
|
+
res.end(JSON.stringify({ plugins, pluginsDir: getPluginsDir() }));
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
if (url === '/api/plugins' && method === 'DELETE') {
|
|
2267
|
+
const file = parsedUrl.searchParams.get('file');
|
|
2268
|
+
if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) {
|
|
2269
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2270
|
+
res.end(JSON.stringify({ error: 'Invalid file name' }));
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
const filePath = join(getPluginsDir(), file);
|
|
2274
|
+
try {
|
|
2275
|
+
if (!existsSync(filePath)) {
|
|
2276
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2277
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
unlinkSync(filePath);
|
|
2281
|
+
await loadPlugins();
|
|
2282
|
+
const plugins = getPluginsInfo();
|
|
2283
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2284
|
+
res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
|
|
2285
|
+
} catch (err) {
|
|
2286
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2287
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2288
|
+
}
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (url === '/api/plugins/reload' && method === 'POST') {
|
|
2293
|
+
try {
|
|
2294
|
+
await loadPlugins();
|
|
2295
|
+
const plugins = getPluginsInfo();
|
|
2296
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2297
|
+
res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2300
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2301
|
+
}
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
if (url === '/api/plugins/upload' && method === 'POST') {
|
|
2306
|
+
let body = '';
|
|
2307
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
2308
|
+
req.on('end', async () => {
|
|
2309
|
+
try {
|
|
2310
|
+
const { files: fileList } = JSON.parse(body);
|
|
2311
|
+
uploadPlugins(getPluginsDir(), fileList);
|
|
2312
|
+
await loadPlugins();
|
|
2313
|
+
const plugins = getPluginsInfo();
|
|
2314
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2315
|
+
res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
const status = err.statusCode || 500;
|
|
2318
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2319
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
if (url === '/api/plugins/install-from-url' && method === 'POST') {
|
|
2326
|
+
let body = '';
|
|
2327
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
2328
|
+
req.on('end', async () => {
|
|
2329
|
+
try {
|
|
2330
|
+
const { url: fileUrl } = JSON.parse(body);
|
|
2331
|
+
const extractScript = join(__dirname, 'lib', 'extract-plugin-name.mjs');
|
|
2332
|
+
await installPluginFromUrl(getPluginsDir(), fileUrl, extractScript);
|
|
2333
|
+
await loadPlugins();
|
|
2334
|
+
const plugins = getPluginsInfo();
|
|
2335
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2336
|
+
res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
|
|
2337
|
+
} catch (err) {
|
|
2338
|
+
const status = err.statusCode || 500;
|
|
2339
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2340
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// 返回局域网访问地址
|
|
2347
|
+
if (url === '/api/local-url' && method === 'GET') {
|
|
2348
|
+
const localIp = getLocalIp();
|
|
2349
|
+
const defaultUrl = `${serverProtocol}://${localIp}:${actualPort}?token=${ACCESS_TOKEN}`;
|
|
2350
|
+
const hookResult = await runWaterfallHook('localUrl', { url: defaultUrl, ip: localIp, port: actualPort, token: ACCESS_TOKEN });
|
|
2351
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2352
|
+
res.end(JSON.stringify({ url: hookResult.url }));
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// 列出本地日志文件(按项目分组,遍历项目子目录)
|
|
2357
|
+
if (url === '/api/local-logs' && method === 'GET') {
|
|
2358
|
+
try {
|
|
2359
|
+
const result = listLocalLogs(LOG_DIR, _projectName);
|
|
2360
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2361
|
+
res.end(JSON.stringify(result));
|
|
2362
|
+
} catch (err) {
|
|
2363
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2364
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2365
|
+
}
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// 下载指定本地日志文件(原始 JSONL 格式)
|
|
2370
|
+
if (url === '/api/download-log' && method === 'GET') {
|
|
2371
|
+
const file = parsedUrl.searchParams.get('file');
|
|
2372
|
+
if (!file || file.includes('..')) {
|
|
2373
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2374
|
+
res.end(JSON.stringify({ error: 'Invalid file name' }));
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (!file.endsWith('.jsonl')) {
|
|
2378
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2379
|
+
res.end(JSON.stringify({ error: 'Invalid file type' }));
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
const filePath = join(LOG_DIR, file);
|
|
2383
|
+
try {
|
|
2384
|
+
if (!existsSync(filePath)) {
|
|
2385
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2386
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
const realPath = realpathSync(filePath);
|
|
2390
|
+
const realLogDir = realpathSync(LOG_DIR);
|
|
2391
|
+
if (!realPath.startsWith(realLogDir)) {
|
|
2392
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2393
|
+
res.end(JSON.stringify({ error: 'Access denied' }));
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const fileName = file.split('/').pop();
|
|
2397
|
+
const format = parsedUrl.searchParams.get('format');
|
|
2398
|
+
// Delta storage: format=raw 下载原始文件;默认下载重建后的全量格式
|
|
2399
|
+
if (format === 'raw') {
|
|
2400
|
+
const stat = statSync(realPath);
|
|
2401
|
+
res.writeHead(200, {
|
|
2402
|
+
'Content-Type': 'application/octet-stream',
|
|
2403
|
+
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
|
|
2404
|
+
'Content-Length': stat.size,
|
|
2405
|
+
});
|
|
2406
|
+
const stream = createReadStream(realPath);
|
|
2407
|
+
stream.pipe(res);
|
|
2408
|
+
} else {
|
|
2409
|
+
// 流式下载原始条目(不重建,保持 delta 格式),避免 OOM
|
|
2410
|
+
res.writeHead(200, {
|
|
2411
|
+
'Content-Type': 'application/octet-stream',
|
|
2412
|
+
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
|
|
2413
|
+
'Transfer-Encoding': 'chunked',
|
|
2414
|
+
});
|
|
2415
|
+
await streamRawEntriesAsync(realPath, (raw) => {
|
|
2416
|
+
res.write(raw);
|
|
2417
|
+
res.write('\n---\n');
|
|
2418
|
+
});
|
|
2419
|
+
res.end();
|
|
2420
|
+
}
|
|
2421
|
+
} catch (err) {
|
|
2422
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2423
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2424
|
+
}
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// 读取指定本地日志文件(支持 project/file 路径)
|
|
2429
|
+
if (url === '/api/local-log' && method === 'GET') {
|
|
2430
|
+
const file = parsedUrl.searchParams.get('file');
|
|
2431
|
+
if (!file || file.includes('..')) {
|
|
2432
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2433
|
+
res.end(JSON.stringify({ error: 'Invalid file name' }));
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// 验证文件类型:只允许 .jsonl 文件
|
|
2438
|
+
if (!file.endsWith('.jsonl')) {
|
|
2439
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2440
|
+
res.end(JSON.stringify({ error: 'Invalid file type. Only .jsonl files are allowed.' }));
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
try {
|
|
2445
|
+
// 独立 SSE 流:直接向请求方返回 event-stream,不走 /events 广播
|
|
2446
|
+
const { validateLogPath } = await import('./lib/log-management.js');
|
|
2447
|
+
validateLogPath(LOG_DIR, file);
|
|
2448
|
+
const filePath = join(LOG_DIR, file);
|
|
2449
|
+
const total = countLogEntries(filePath);
|
|
2450
|
+
|
|
2451
|
+
res.writeHead(200, {
|
|
2452
|
+
'Content-Type': 'text/event-stream',
|
|
2453
|
+
'Cache-Control': 'no-cache',
|
|
2454
|
+
'Connection': 'keep-alive',
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
res.write(`event: load_start\ndata: ${JSON.stringify({ total, incremental: false })}\n\n`);
|
|
2458
|
+
await streamRawEntriesAsync(filePath, (raw) => {
|
|
2459
|
+
res.write('event: load_chunk\ndata: [');
|
|
2460
|
+
res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
|
|
2461
|
+
res.write(']\n\n');
|
|
2462
|
+
});
|
|
2463
|
+
res.write(`event: load_end\ndata: {}\n\n`);
|
|
2464
|
+
res.end();
|
|
2465
|
+
} catch (err) {
|
|
2466
|
+
// 如果 headers 未发送,返回 JSON 错误;否则关闭连接
|
|
2467
|
+
if (!res.headersSent) {
|
|
2468
|
+
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
|
|
2469
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2470
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2471
|
+
} else {
|
|
2472
|
+
res.end();
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// 删除日志文件
|
|
2479
|
+
// 清除当前日志文件内容
|
|
2480
|
+
if (url === '/api/clear-current-log' && method === 'POST') {
|
|
2481
|
+
try {
|
|
2482
|
+
if (LOG_FILE && existsSync(LOG_FILE)) {
|
|
2483
|
+
writeFileSync(LOG_FILE, '');
|
|
2484
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2485
|
+
res.end(JSON.stringify({ ok: true, file: LOG_FILE }));
|
|
2486
|
+
} else {
|
|
2487
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2488
|
+
res.end(JSON.stringify({ ok: true, file: null }));
|
|
2489
|
+
}
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2492
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2493
|
+
}
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
if (url === '/api/delete-logs' && method === 'POST') {
|
|
2498
|
+
let body = '';
|
|
2499
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
2500
|
+
req.on('end', () => {
|
|
2501
|
+
try {
|
|
2502
|
+
const { files } = JSON.parse(body);
|
|
2503
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
2504
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2505
|
+
res.end(JSON.stringify({ error: 'No files specified' }));
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
const results = deleteLogFiles(LOG_DIR, files);
|
|
2509
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2510
|
+
res.end(JSON.stringify({ results }));
|
|
2511
|
+
} catch {
|
|
2512
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2513
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// 合并日志文件
|
|
2520
|
+
if (url === '/api/merge-logs' && method === 'POST') {
|
|
2521
|
+
let body = '';
|
|
2522
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
2523
|
+
req.on('end', () => {
|
|
2524
|
+
try {
|
|
2525
|
+
const { files } = JSON.parse(body);
|
|
2526
|
+
const merged = mergeLogFiles(LOG_DIR, files);
|
|
2527
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2528
|
+
res.end(JSON.stringify({ ok: true, merged }));
|
|
2529
|
+
} catch (err) {
|
|
2530
|
+
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'INVALID_INPUT' ? 400 : 500;
|
|
2531
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2532
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2533
|
+
}
|
|
2534
|
+
});
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// GET /api/concept?lang=zh&doc=Tool-Bash
|
|
2539
|
+
if (method === 'GET' && url === '/api/concept') {
|
|
2540
|
+
const lang = parsedUrl.searchParams.get('lang') || 'zh';
|
|
2541
|
+
const doc = parsedUrl.searchParams.get('doc') || '';
|
|
2542
|
+
// 安全校验:只允许字母、数字、连字符
|
|
2543
|
+
if (!/^[a-zA-Z0-9-]+$/.test(doc) || !/^[a-z]{2}(-[a-zA-Z]{2,})?$/.test(lang)) {
|
|
2544
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2545
|
+
res.end(JSON.stringify({ error: 'Invalid parameters' }));
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
let mdPath = join(__dirname, 'concepts', lang, `${doc}.md`);
|
|
2549
|
+
if (!existsSync(mdPath) && lang !== 'zh') {
|
|
2550
|
+
mdPath = join(__dirname, 'concepts', 'zh', `${doc}.md`);
|
|
2551
|
+
}
|
|
2552
|
+
if (existsSync(mdPath)) {
|
|
2553
|
+
const content = readFileSync(mdPath, 'utf-8');
|
|
2554
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
|
|
2555
|
+
res.end(content);
|
|
2556
|
+
} else {
|
|
2557
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2558
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
2559
|
+
}
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// CXV 进程列表
|
|
2564
|
+
if (url === '/api/cxv-processes' && method === 'GET') {
|
|
2565
|
+
if (platform() === 'win32') {
|
|
2566
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2567
|
+
res.end(JSON.stringify({ processes: [] }));
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
try {
|
|
2571
|
+
const { stdout } = await execAsync('lsof -iTCP:7008-7099 -sTCP:LISTEN -P -n', { timeout: 5000 }).catch(() => ({ stdout: '' }));
|
|
2572
|
+
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
2573
|
+
// Parse lsof output: skip header, filter node processes, dedupe by PID:port
|
|
2574
|
+
const seen = new Map(); // pid -> port
|
|
2575
|
+
for (const line of lines.slice(1)) {
|
|
2576
|
+
const parts = line.trim().split(/\s+/);
|
|
2577
|
+
const cmd = parts[0];
|
|
2578
|
+
if (cmd !== 'node') continue;
|
|
2579
|
+
const pid = parseInt(parts[1], 10);
|
|
2580
|
+
if (!pid) continue;
|
|
2581
|
+
// lsof 输出: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME (STATE)
|
|
2582
|
+
// 端口在 NAME 列(倒数第二列),如 *:7008,最后一列是 (LISTEN)
|
|
2583
|
+
const nameField = parts[parts.length - 2] || '';
|
|
2584
|
+
const portMatch = nameField.match(/:(\d+)$/);
|
|
2585
|
+
if (!portMatch) continue;
|
|
2586
|
+
const port = portMatch[1];
|
|
2587
|
+
if (!seen.has(pid)) seen.set(pid, port);
|
|
2588
|
+
}
|
|
2589
|
+
// 获取所有候选进程的 PPID,过滤掉 PPID 也在 CXV 进程集合中的子进程(即 cxv -c/-d 启动的 codex 子进程)
|
|
2590
|
+
const cxvPids = new Set(seen.keys());
|
|
2591
|
+
const filteredPids = [];
|
|
2592
|
+
for (const [pid] of seen) {
|
|
2593
|
+
try {
|
|
2594
|
+
const { stdout: ppidOut } = await execAsync(`ps -o ppid= -p ${pid}`, { timeout: 2000 }).catch(() => ({ stdout: '' }));
|
|
2595
|
+
const ppid = parseInt(ppidOut.trim(), 10);
|
|
2596
|
+
if (ppid && cxvPids.has(ppid)) continue; // 是某个 CXV 进程的子进程,跳过
|
|
2597
|
+
} catch {}
|
|
2598
|
+
filteredPids.push(pid);
|
|
2599
|
+
}
|
|
2600
|
+
const processes = [];
|
|
2601
|
+
for (const pid of filteredPids) {
|
|
2602
|
+
const port = seen.get(pid);
|
|
2603
|
+
let startTime = '';
|
|
2604
|
+
let command = '';
|
|
2605
|
+
try {
|
|
2606
|
+
const { stdout: psOut } = await execAsync(`ps -p ${pid} -o lstart=,command=`, { timeout: 3000 }).catch(() => ({ stdout: '' }));
|
|
2607
|
+
const psLine = psOut.trim();
|
|
2608
|
+
// lstart format: "Day Mon DD HH:MM:SS YYYY rest..."
|
|
2609
|
+
const lsMatch = psLine.match(/^\w+\s+(\w+)\s+(\d+)\s+([\d:]+)\s+(\d{4})\s+(.*)/);
|
|
2610
|
+
if (lsMatch) {
|
|
2611
|
+
const months = { Jan:1,Feb:2,Mar:3,Apr:4,May:5,Jun:6,Jul:7,Aug:8,Sep:9,Oct:10,Nov:11,Dec:12 };
|
|
2612
|
+
const mon = String(months[lsMatch[1]] || 1).padStart(2, '0');
|
|
2613
|
+
const day = String(lsMatch[2]).padStart(2, '0');
|
|
2614
|
+
const time = lsMatch[3];
|
|
2615
|
+
const year = lsMatch[4];
|
|
2616
|
+
startTime = `${year}年${mon}月${day}日 ${time}`;
|
|
2617
|
+
const rawCmd = lsMatch[5];
|
|
2618
|
+
// Extract path after lib/ (e.g. node_modules/cx-viewer/cli.js -d → cx-viewer/cli.js -d)
|
|
2619
|
+
const libMatch = rawCmd.match(/lib\/(.+)/);
|
|
2620
|
+
command = libMatch ? libMatch[1] : rawCmd;
|
|
2621
|
+
}
|
|
2622
|
+
} catch {}
|
|
2623
|
+
const isCurrent = pid === process.pid;
|
|
2624
|
+
processes.push({ port, pid, command, startTime, isCurrent });
|
|
2625
|
+
}
|
|
2626
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2627
|
+
res.end(JSON.stringify({ processes }));
|
|
2628
|
+
} catch (err) {
|
|
2629
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2630
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2631
|
+
}
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// CXV 进程关闭
|
|
2636
|
+
if (url === '/api/cxv-processes/kill' && method === 'POST') {
|
|
2637
|
+
let body = '';
|
|
2638
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
|
|
2639
|
+
req.on('end', async () => {
|
|
2640
|
+
try {
|
|
2641
|
+
const { pid } = JSON.parse(body);
|
|
2642
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
2643
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2644
|
+
res.end(JSON.stringify({ error: 'Invalid PID' }));
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
if (pid === process.pid) {
|
|
2648
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2649
|
+
res.end(JSON.stringify({ error: 'Cannot kill current process' }));
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
// 安全检查:确认是监听 CXV 端口范围 (7008-7099) 的 node 进程
|
|
2653
|
+
const { stdout: lsofOut } = await execAsync(`lsof -iTCP:7008-7099 -sTCP:LISTEN -P -n -p ${pid}`, { timeout: 5000 }).catch(() => ({ stdout: '' }));
|
|
2654
|
+
const lsofLines = lsofOut.trim().split('\n').filter(Boolean).slice(1);
|
|
2655
|
+
const isNodeOnCcvPort = lsofLines.some(line => line.trim().split(/\s+/)[0] === 'node');
|
|
2656
|
+
if (!isNodeOnCcvPort) {
|
|
2657
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2658
|
+
res.end(JSON.stringify({ error: 'Not a CXV process' }));
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
process.kill(pid, 'SIGTERM');
|
|
2662
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2663
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2664
|
+
} catch (err) {
|
|
2665
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2666
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2667
|
+
}
|
|
2668
|
+
});
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// 静态文件服务
|
|
2673
|
+
if (method === 'GET') {
|
|
2674
|
+
let filePath = url === '/' ? '/index.html' : url;
|
|
2675
|
+
// 去掉 query string
|
|
2676
|
+
filePath = filePath.split('?')[0];
|
|
2677
|
+
|
|
2678
|
+
const fullPath = join(__dirname, 'dist', filePath);
|
|
2679
|
+
|
|
2680
|
+
try {
|
|
2681
|
+
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
|
|
2682
|
+
const content = readFileSync(fullPath);
|
|
2683
|
+
const ext = extname(filePath);
|
|
2684
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
2685
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
2686
|
+
res.end(content);
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
} catch (err) {
|
|
2690
|
+
// fall through to SPA fallback
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// SPA fallback: 非 API/非静态文件请求返回 index.html
|
|
2694
|
+
try {
|
|
2695
|
+
const indexPath = join(__dirname, 'dist', 'index.html');
|
|
2696
|
+
const html = readFileSync(indexPath, 'utf-8');
|
|
2697
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2698
|
+
res.end(html);
|
|
2699
|
+
} catch (err) {
|
|
2700
|
+
res.writeHead(404);
|
|
2701
|
+
res.end('Not Found');
|
|
2702
|
+
}
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// 非 GET 请求的 API 404
|
|
2707
|
+
if (url.startsWith('/api/')) {
|
|
2708
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2709
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
res.writeHead(404);
|
|
2714
|
+
res.end('Not Found');
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
export async function startViewer() {
|
|
2718
|
+
// 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
|
|
2719
|
+
await loadPlugins();
|
|
2720
|
+
|
|
2721
|
+
// 通过插件 hook 获取 HTTPS 证书选项
|
|
2722
|
+
let httpsOptions = null;
|
|
2723
|
+
try {
|
|
2724
|
+
const httpsResult = await runWaterfallHook('httpsOptions', {});
|
|
2725
|
+
httpsOptions = (httpsResult.pfx || httpsResult.cert) ? httpsResult : null;
|
|
2726
|
+
} catch (err) {
|
|
2727
|
+
console.error('[CX Viewer] httpsOptions hook error:', err.message);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
const useHttps = !!httpsOptions;
|
|
2731
|
+
const protocol = useHttps ? 'https' : 'http';
|
|
2732
|
+
serverProtocol = protocol;
|
|
2733
|
+
if (useHttps) console.error('[CX Viewer] HTTPS mode enabled via plugin hook');
|
|
2734
|
+
|
|
2735
|
+
return new Promise((resolve, reject) => {
|
|
2736
|
+
function tryListen(port) {
|
|
2737
|
+
if (port > MAX_PORT) {
|
|
2738
|
+
console.error(t('server.portsBusy', { start: START_PORT, end: MAX_PORT }));
|
|
2739
|
+
resolve(null);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// 先检测 127.0.0.1:port 是否已被占用(避免 0.0.0.0 和 127.0.0.1 绑定不冲突的问题)
|
|
2744
|
+
const probe = createConnection({ host: '127.0.0.1', port });
|
|
2745
|
+
probe.on('connect', () => {
|
|
2746
|
+
probe.destroy();
|
|
2747
|
+
tryListen(port + 1); // 端口已被占用,尝试下一个
|
|
2748
|
+
});
|
|
2749
|
+
probe.on('error', () => {
|
|
2750
|
+
probe.destroy();
|
|
2751
|
+
// 端口空闲,绑定
|
|
2752
|
+
let currentServer;
|
|
2753
|
+
if (useHttps) {
|
|
2754
|
+
try {
|
|
2755
|
+
currentServer = createHttpsServer(httpsOptions, handleRequest);
|
|
2756
|
+
} catch (err) {
|
|
2757
|
+
console.error('[CX Viewer] HTTPS server creation failed, falling back to HTTP:', err.message);
|
|
2758
|
+
currentServer = createServer(handleRequest);
|
|
2759
|
+
serverProtocol = 'http';
|
|
2760
|
+
}
|
|
2761
|
+
} else {
|
|
2762
|
+
currentServer = createServer(handleRequest);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
currentServer.listen(port, HOST, async () => {
|
|
2766
|
+
server = currentServer;
|
|
2767
|
+
actualPort = port;
|
|
2768
|
+
const url = `${serverProtocol}://127.0.0.1:${port}`;
|
|
2769
|
+
if (!isCliMode) {
|
|
2770
|
+
console.error(t('server.started'));
|
|
2771
|
+
console.error(t('server.startedLocal', { protocol: serverProtocol, port }));
|
|
2772
|
+
const _ips = getAllLocalIps();
|
|
2773
|
+
for (const _ip of _ips) {
|
|
2774
|
+
console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, token: ACCESS_TOKEN }));
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
// v2.0.69 之前的版本会清空控制台,自动打开浏览器确保用户能看到界面
|
|
2778
|
+
try {
|
|
2779
|
+
const ccPkgPath = join(__dirname, '..', '@openai', 'codex', 'package.json');
|
|
2780
|
+
const ccVer = JSON.parse(readFileSync(ccPkgPath, 'utf-8')).version;
|
|
2781
|
+
const [maj, min, pat] = ccVer.split('.').map(Number);
|
|
2782
|
+
if (maj < 2 || (maj === 2 && min === 0 && pat < 69)) {
|
|
2783
|
+
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
|
|
2784
|
+
execAsync(`${cmd} ${url}`, { timeout: 5000 }).catch(() => {});
|
|
2785
|
+
}
|
|
2786
|
+
} catch { }
|
|
2787
|
+
// 工作区模式下延迟到选择工作区后再启动监听
|
|
2788
|
+
if (!isWorkspaceMode) {
|
|
2789
|
+
readModelContextSize(); // Cache model→size mapping at startup
|
|
2790
|
+
startWatching(_logWatcherOpts(LOG_FILE));
|
|
2791
|
+
startStatsWorker();
|
|
2792
|
+
startStreamingStatusTimer();
|
|
2793
|
+
}
|
|
2794
|
+
// CLI 模式下启动 WebSocket 服务 (必须 await,否则插件 hook 拿不到 upgrade listeners)
|
|
2795
|
+
if (isCliMode) {
|
|
2796
|
+
await setupTerminalWebSocket(currentServer);
|
|
2797
|
+
}
|
|
2798
|
+
// 通知插件服务器已启动
|
|
2799
|
+
runParallelHook('serverStarted', { port, host: HOST, url, ip: getLocalIp(), token: ACCESS_TOKEN, protocol: serverProtocol, httpServer: currentServer })
|
|
2800
|
+
.catch(err => console.error('[CX Viewer] Plugin serverStarted hook error:', err.message));
|
|
2801
|
+
resolve(server);
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
currentServer.on('error', (err) => {
|
|
2805
|
+
if (err.code === 'EADDRINUSE') {
|
|
2806
|
+
tryListen(port + 1);
|
|
2807
|
+
} else {
|
|
2808
|
+
reject(err);
|
|
2809
|
+
}
|
|
2810
|
+
});
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
tryListen(START_PORT);
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
async function setupTerminalWebSocket(httpServer) {
|
|
2819
|
+
try {
|
|
2820
|
+
const { WebSocketServer } = await import('ws');
|
|
2821
|
+
const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
|
|
2822
|
+
_writeToPty = writeToPty;
|
|
2823
|
+
_onPtyData = onPtyData;
|
|
2824
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
2825
|
+
terminalWss = wss;
|
|
2826
|
+
|
|
2827
|
+
// 多客户端共享 PTY 的尺寸冲突解决:
|
|
2828
|
+
// 移动端优先——只要有移动端在线,PTY 始终使用移动端尺寸,
|
|
2829
|
+
// PC 端的 resize 仅存储不生效,避免宽屏尺寸导致移动端乱码。
|
|
2830
|
+
// PC 端显示窄输出但完全可读,移动端永远不会乱码。
|
|
2831
|
+
let activeWs = null; // 当前活跃的 WebSocket 连接
|
|
2832
|
+
const clientSizes = new Map(); // ws → { cols, rows }
|
|
2833
|
+
const mobileClients = new Set(); // 移动端连接集合
|
|
2834
|
+
|
|
2835
|
+
// 找到一个在线的移动端并返回其尺寸
|
|
2836
|
+
const getMobileSize = () => {
|
|
2837
|
+
for (const mws of mobileClients) {
|
|
2838
|
+
if (mws.readyState === 1) {
|
|
2839
|
+
const size = clientSizes.get(mws);
|
|
2840
|
+
if (size) return size;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
return null;
|
|
2844
|
+
};
|
|
2845
|
+
|
|
2846
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
2847
|
+
const pathname = new URL(req.url, `${serverProtocol}://${req.headers.host}`).pathname;
|
|
2848
|
+
if (pathname === '/ws/terminal') {
|
|
2849
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2850
|
+
wss.emit('connection', ws, req);
|
|
2851
|
+
});
|
|
2852
|
+
} else {
|
|
2853
|
+
socket.destroy();
|
|
2854
|
+
}
|
|
2855
|
+
});
|
|
2856
|
+
|
|
2857
|
+
wss.on('connection', (ws) => {
|
|
2858
|
+
// 发送当前 PTY 状态
|
|
2859
|
+
const state = getPtyState();
|
|
2860
|
+
ws.send(JSON.stringify({ type: 'state', ...state }));
|
|
2861
|
+
|
|
2862
|
+
// 发送历史输出缓冲
|
|
2863
|
+
const buffer = getOutputBuffer();
|
|
2864
|
+
if (buffer) {
|
|
2865
|
+
ws.send(JSON.stringify({ type: 'data', data: buffer }));
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// PTY 输出 → WebSocket
|
|
2869
|
+
const removeDataListener = onPtyData((data) => {
|
|
2870
|
+
if (ws.readyState === 1) {
|
|
2871
|
+
ws.send(JSON.stringify({ type: 'data', data }));
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
|
|
2875
|
+
// PTY 退出 → WebSocket
|
|
2876
|
+
const removeExitListener = onPtyExit((exitCode) => {
|
|
2877
|
+
if (ws.readyState === 1) {
|
|
2878
|
+
ws.send(JSON.stringify({ type: 'exit', exitCode }));
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
2881
|
+
|
|
2882
|
+
// WebSocket → PTY
|
|
2883
|
+
ws.on('message', async (raw) => {
|
|
2884
|
+
try {
|
|
2885
|
+
const msg = JSON.parse(raw.toString());
|
|
2886
|
+
if (msg.type === 'input') {
|
|
2887
|
+
// PTY 已退出时,自动 spawn 交互式 shell
|
|
2888
|
+
const state = getPtyState();
|
|
2889
|
+
if (!state.running) {
|
|
2890
|
+
try {
|
|
2891
|
+
await spawnShell();
|
|
2892
|
+
} catch {}
|
|
2893
|
+
}
|
|
2894
|
+
// 发送 input 的客户端成为活跃客户端
|
|
2895
|
+
if (activeWs !== ws) {
|
|
2896
|
+
activeWs = ws;
|
|
2897
|
+
// 切换活跃客户端时,如果有移动端在线则保持移动端尺寸,
|
|
2898
|
+
// 否则切换到新活跃客户端的尺寸
|
|
2899
|
+
const mSize = getMobileSize();
|
|
2900
|
+
if (mSize) {
|
|
2901
|
+
resizePty(mSize.cols, mSize.rows);
|
|
2902
|
+
} else {
|
|
2903
|
+
const size = clientSizes.get(ws);
|
|
2904
|
+
if (size) {
|
|
2905
|
+
resizePty(size.cols, size.rows);
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
// 拦截连续 Ctrl+C:2秒内连按2次则阻止并提醒,避免误退出 CLI
|
|
2910
|
+
if (msg.data === '\x03') {
|
|
2911
|
+
const now = Date.now();
|
|
2912
|
+
if (!ws._ctrlCLastTime) ws._ctrlCLastTime = 0;
|
|
2913
|
+
if (now - ws._ctrlCLastTime < 2000) {
|
|
2914
|
+
ws._ctrlCLastTime = 0;
|
|
2915
|
+
try { ws.send(JSON.stringify({ type: 'toast', message: t('ui.terminal.ctrlCBlocked') })); } catch {}
|
|
2916
|
+
// 不发送第二次 Ctrl+C 到 PTY
|
|
2917
|
+
} else {
|
|
2918
|
+
ws._ctrlCLastTime = now;
|
|
2919
|
+
writeToPty(msg.data);
|
|
2920
|
+
}
|
|
2921
|
+
} else {
|
|
2922
|
+
writeToPty(msg.data);
|
|
2923
|
+
}
|
|
2924
|
+
} else if (msg.type === 'input-sequential') {
|
|
2925
|
+
// Programmatic sequential input: send chunks one by one, waiting for PTY ACK
|
|
2926
|
+
const state = getPtyState();
|
|
2927
|
+
if (!state.running) {
|
|
2928
|
+
try { await spawnShell(); } catch {}
|
|
2929
|
+
}
|
|
2930
|
+
const chunks = msg.chunks;
|
|
2931
|
+
if (Array.isArray(chunks) && chunks.length > 0) {
|
|
2932
|
+
writeToPtySequential(chunks, (ok) => {
|
|
2933
|
+
try {
|
|
2934
|
+
ws.send(JSON.stringify({ type: 'input-sequential-done', ok }));
|
|
2935
|
+
} catch {}
|
|
2936
|
+
}, { settleMs: msg.settleMs || 150 });
|
|
2937
|
+
}
|
|
2938
|
+
} else if (msg.type === 'ask-hook-answer') {
|
|
2939
|
+
// Client answered AskUserQuestion via hook bridge
|
|
2940
|
+
let askAnswered = false;
|
|
2941
|
+
if (pendingAskHook) {
|
|
2942
|
+
const { res: hookRes, timer } = pendingAskHook;
|
|
2943
|
+
clearTimeout(timer);
|
|
2944
|
+
pendingAskHook = null;
|
|
2945
|
+
askAnswered = true;
|
|
2946
|
+
try {
|
|
2947
|
+
if (!hookRes.headersSent) {
|
|
2948
|
+
hookRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2949
|
+
hookRes.end(JSON.stringify({ answers: msg.answers }));
|
|
2950
|
+
}
|
|
2951
|
+
} catch {}
|
|
2952
|
+
}
|
|
2953
|
+
// Broadcast resolved to other clients so they clear their ask panel
|
|
2954
|
+
if (askAnswered && terminalWss) {
|
|
2955
|
+
const rmsg = JSON.stringify({ type: 'ask-hook-resolved' });
|
|
2956
|
+
terminalWss.clients.forEach((c) => {
|
|
2957
|
+
if (c !== ws && c.readyState === 1) try { c.send(rmsg); } catch {}
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
} else if (msg.type === 'perm-hook-answer') {
|
|
2961
|
+
// Permission approval — SDK mode (canUseTool) or PTY mode (hook bridge)
|
|
2962
|
+
let permAnswered = false;
|
|
2963
|
+
if (isSdkMode && _sdkResolveApproval && msg.id) {
|
|
2964
|
+
_sdkResolveApproval(msg.id, msg.allowSession ? { decision: msg.decision || 'allow', allowSession: true } : (msg.decision || 'deny'));
|
|
2965
|
+
permAnswered = true;
|
|
2966
|
+
} else if (pendingPermHook && msg.id && msg.id === pendingPermHook.id) {
|
|
2967
|
+
const { res: hookRes, timer } = pendingPermHook;
|
|
2968
|
+
clearTimeout(timer);
|
|
2969
|
+
pendingPermHook = null;
|
|
2970
|
+
permAnswered = true;
|
|
2971
|
+
try {
|
|
2972
|
+
if (!hookRes.headersSent) {
|
|
2973
|
+
hookRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2974
|
+
hookRes.end(JSON.stringify({ decision: msg.decision || 'deny' }));
|
|
2975
|
+
}
|
|
2976
|
+
} catch {}
|
|
2977
|
+
}
|
|
2978
|
+
// Broadcast resolved only when an answer was actually processed
|
|
2979
|
+
if (permAnswered && terminalWss) {
|
|
2980
|
+
const rmsg = JSON.stringify({ type: 'perm-hook-resolved', id: msg.id });
|
|
2981
|
+
terminalWss.clients.forEach((c) => {
|
|
2982
|
+
if (c !== ws && c.readyState === 1) try { c.send(rmsg); } catch {}
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
} else if (msg.type === 'sdk-ask-answer') {
|
|
2986
|
+
// AskUserQuestion answer in SDK mode — resolve canUseTool Promise
|
|
2987
|
+
if (_sdkResolveApproval && msg.id) {
|
|
2988
|
+
_sdkResolveApproval(msg.id, msg.answers);
|
|
2989
|
+
}
|
|
2990
|
+
// Broadcast resolved to other clients
|
|
2991
|
+
if (msg.id && terminalWss) {
|
|
2992
|
+
const rmsg = JSON.stringify({ type: 'sdk-ask-resolved', id: msg.id });
|
|
2993
|
+
terminalWss.clients.forEach((c) => {
|
|
2994
|
+
if (c !== ws && c.readyState === 1) try { c.send(rmsg); } catch {}
|
|
2995
|
+
});
|
|
2996
|
+
}
|
|
2997
|
+
} else if (msg.type === 'sdk-plan-answer') {
|
|
2998
|
+
// Plan approval in SDK mode
|
|
2999
|
+
if (_sdkResolveApproval) {
|
|
3000
|
+
_sdkResolveApproval(msg.id, { approve: msg.approve !== false, feedback: msg.feedback || '' });
|
|
3001
|
+
}
|
|
3002
|
+
// Broadcast resolved to other clients
|
|
3003
|
+
if (terminalWss) {
|
|
3004
|
+
const rmsg = JSON.stringify({ type: 'sdk-plan-resolved', id: msg.id });
|
|
3005
|
+
terminalWss.clients.forEach((c) => {
|
|
3006
|
+
if (c !== ws && c.readyState === 1) try { c.send(rmsg); } catch {}
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
} else if (msg.type === 'sdk-user-message') {
|
|
3010
|
+
// User message in SDK mode — relay to sdk-manager
|
|
3011
|
+
if (_sdkSendUserMessage && msg.text) {
|
|
3012
|
+
_sdkSendUserMessage(msg.text).catch(err => {
|
|
3013
|
+
console.error('[SDK] sendUserMessage error:', err.message);
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
} else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
|
|
3017
|
+
// Security: only allow paths within upload directories, reject traversal
|
|
3018
|
+
const p = msg.path;
|
|
3019
|
+
if (terminalWss && p && !p.includes('..') && (
|
|
3020
|
+
p.startsWith('/tmp/cx-viewer-uploads/') || (p.includes('/cx-viewer/') && p.includes('/images/'))
|
|
3021
|
+
)) {
|
|
3022
|
+
const rmsg = msg.type === 'image-upload-notify'
|
|
3023
|
+
? JSON.stringify({ type: 'image-upload-notify', path: p, source: msg.source || 'unknown' })
|
|
3024
|
+
: JSON.stringify({ type: 'image-remove-notify', path: p });
|
|
3025
|
+
terminalWss.clients.forEach((c) => {
|
|
3026
|
+
if (c !== ws && c.readyState === 1) try { c.send(rmsg); } catch {}
|
|
3027
|
+
});
|
|
3028
|
+
}
|
|
3029
|
+
} else if (msg.type === 'resize') {
|
|
3030
|
+
// 存储该客户端的尺寸
|
|
3031
|
+
clientSizes.set(ws, { cols: msg.cols, rows: msg.rows });
|
|
3032
|
+
if (msg.mobile) mobileClients.add(ws);
|
|
3033
|
+
// 移动端 resize 始终生效;PC 端仅在无移动端时生效
|
|
3034
|
+
if (msg.mobile) {
|
|
3035
|
+
resizePty(msg.cols, msg.rows);
|
|
3036
|
+
} else if (mobileClients.size === 0 && (activeWs === ws || activeWs === null)) {
|
|
3037
|
+
activeWs = ws;
|
|
3038
|
+
resizePty(msg.cols, msg.rows);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
} catch {}
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
ws.on('close', () => {
|
|
3045
|
+
removeDataListener();
|
|
3046
|
+
removeExitListener();
|
|
3047
|
+
clientSizes.delete(ws);
|
|
3048
|
+
mobileClients.delete(ws);
|
|
3049
|
+
if (activeWs === ws) {
|
|
3050
|
+
// 活跃客户端断开,将控制权交给剩余的某个客户端
|
|
3051
|
+
activeWs = null;
|
|
3052
|
+
// 优先使用移动端尺寸,无移动端则用剩余客户端尺寸
|
|
3053
|
+
const mSize = getMobileSize();
|
|
3054
|
+
if (mSize) {
|
|
3055
|
+
resizePty(mSize.cols, mSize.rows);
|
|
3056
|
+
} else {
|
|
3057
|
+
for (const [remainWs, size] of clientSizes) {
|
|
3058
|
+
if (remainWs.readyState === 1) {
|
|
3059
|
+
activeWs = remainWs;
|
|
3060
|
+
resizePty(size.cols, size.rows);
|
|
3061
|
+
break;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
});
|
|
3067
|
+
});
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
console.error('[CX Viewer] Failed to setup terminal WebSocket:', err.message);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
export function getPort() {
|
|
3074
|
+
return actualPort;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
export function getProtocol() {
|
|
3078
|
+
return serverProtocol;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
export { getAllLocalIps };
|
|
3082
|
+
|
|
3083
|
+
export function getAccessToken() {
|
|
3084
|
+
return ACCESS_TOKEN;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端
|
|
3088
|
+
let _streamingStatusTimer = null;
|
|
3089
|
+
let _lastStreamingActive = false;
|
|
3090
|
+
function startStreamingStatusTimer() {
|
|
3091
|
+
if (_streamingStatusTimer) return;
|
|
3092
|
+
_streamingStatusTimer = setInterval(() => {
|
|
3093
|
+
// SDK mode uses its own streaming state (pushed directly via setSdkStreamingState)
|
|
3094
|
+
if (isSdkMode) return;
|
|
3095
|
+
const changed = streamingState.active !== _lastStreamingActive;
|
|
3096
|
+
if (changed || streamingState.active) {
|
|
3097
|
+
const data = streamingState.active
|
|
3098
|
+
? { ...streamingState, elapsed: Date.now() - streamingState.startTime }
|
|
3099
|
+
: { active: false };
|
|
3100
|
+
if (clients.length > 0 && sendEventToClients) sendEventToClients(clients, 'streaming_status', data);
|
|
3101
|
+
_lastStreamingActive = streamingState.active;
|
|
3102
|
+
}
|
|
3103
|
+
}, 500);
|
|
3104
|
+
_streamingStatusTimer.unref();
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
let _stoppingPromise = null;
|
|
3108
|
+
export function stopViewer() {
|
|
3109
|
+
if (_stoppingPromise) return _stoppingPromise;
|
|
3110
|
+
_stoppingPromise = _doStop();
|
|
3111
|
+
return _stoppingPromise;
|
|
3112
|
+
}
|
|
3113
|
+
async function _doStop() {
|
|
3114
|
+
try { await Promise.race([runParallelHook('serverStopping'), new Promise(r => setTimeout(r, 3000))]); } catch { }
|
|
3115
|
+
// 如果用户未做选择,将临时文件转为正式文件
|
|
3116
|
+
if (_resumeState && _resumeState.tempFile) {
|
|
3117
|
+
try {
|
|
3118
|
+
const { tempFile } = _resumeState;
|
|
3119
|
+
if (existsSync(tempFile)) {
|
|
3120
|
+
// 只有非空 temp 文件才 rename 为正式文件,空文件直接删除
|
|
3121
|
+
const sz = statSync(tempFile).size;
|
|
3122
|
+
if (sz > 0) {
|
|
3123
|
+
const newPath = tempFile.replace('_temp.jsonl', '.jsonl');
|
|
3124
|
+
renameSync(tempFile, newPath);
|
|
3125
|
+
} else {
|
|
3126
|
+
unlinkSync(tempFile);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
} catch { }
|
|
3130
|
+
}
|
|
3131
|
+
for (const logFile of getWatchedFiles().keys()) {
|
|
3132
|
+
unwatchFile(logFile);
|
|
3133
|
+
}
|
|
3134
|
+
unwatchFile(CONTEXT_WINDOW_FILE);
|
|
3135
|
+
getWatchedFiles().clear();
|
|
3136
|
+
clients.forEach(client => client.end());
|
|
3137
|
+
clients = [];
|
|
3138
|
+
if (server) {
|
|
3139
|
+
// 销毁所有活跃连接,防止 keep-alive 阻止进程退出
|
|
3140
|
+
server.closeAllConnections();
|
|
3141
|
+
server.close();
|
|
3142
|
+
}
|
|
3143
|
+
if (statsWorker) {
|
|
3144
|
+
statsWorker.terminate();
|
|
3145
|
+
statsWorker = null;
|
|
3146
|
+
}
|
|
3147
|
+
if (_streamingStatusTimer) {
|
|
3148
|
+
clearInterval(_streamingStatusTimer);
|
|
3149
|
+
_streamingStatusTimer = null;
|
|
3150
|
+
}
|
|
3151
|
+
resetStreamingState();
|
|
3152
|
+
try { unwatchFile(PROFILE_PATH); } catch {} // 清理 interceptor 的 StatWatcher
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
// ─── SDK Mode Exports ──────────────────────────────────────────
|
|
3156
|
+
|
|
3157
|
+
/** Push a JSONL entry to all SSE clients (for SDK mode). */
|
|
3158
|
+
export function pushSdkEntry(entry) {
|
|
3159
|
+
if (sendToClients) sendToClients(clients, entry);
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
/** Update streaming status (for SDK mode). */
|
|
3163
|
+
export function setSdkStreamingState(data) {
|
|
3164
|
+
if (clients.length > 0 && sendEventToClients) {
|
|
3165
|
+
sendEventToClients(clients, 'streaming_status', data);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
/** Broadcast a message to all terminal WS clients (for SDK canUseTool). */
|
|
3170
|
+
export function broadcastWsMessage(msg) {
|
|
3171
|
+
if (terminalWss) {
|
|
3172
|
+
const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
3173
|
+
terminalWss.clients.forEach((c) => {
|
|
3174
|
+
if (c.readyState === 1) try { c.send(str); } catch {}
|
|
3175
|
+
});
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
/** Reference to sdk-manager's resolveApproval (set by cli.js after import). */
|
|
3180
|
+
let _sdkResolveApproval = null;
|
|
3181
|
+
export function setSdkResolveApproval(fn) { _sdkResolveApproval = fn; }
|
|
3182
|
+
|
|
3183
|
+
/** Reference to sdk-manager's sendUserMessage (set by cli.js after import). */
|
|
3184
|
+
let _sdkSendUserMessage = null;
|
|
3185
|
+
export function setSdkSendUserMessage(fn) { _sdkSendUserMessage = fn; }
|
|
3186
|
+
|
|
3187
|
+
// Auto-start the viewer after log file init completes
|
|
3188
|
+
// 工作区模式下由 cli.js 直接 import server.js 触发启动,跳过 _initPromise 自动启动
|
|
3189
|
+
if (!isWorkspaceMode) {
|
|
3190
|
+
_initPromise.then(() => {
|
|
3191
|
+
startViewer().then((srv) => {
|
|
3192
|
+
if (!srv) return;
|
|
3193
|
+
// 延迟 3 秒异步检查更新
|
|
3194
|
+
setTimeout(() => {
|
|
3195
|
+
checkAndUpdate().then(result => {
|
|
3196
|
+
if (result.status === 'updated') {
|
|
3197
|
+
clients.forEach(client => {
|
|
3198
|
+
try { client.write(`event: update_completed\ndata: ${JSON.stringify({ version: result.remoteVersion })}\n\n`); } catch { }
|
|
3199
|
+
});
|
|
3200
|
+
} else if (result.status === 'major_available') {
|
|
3201
|
+
clients.forEach(client => {
|
|
3202
|
+
try { client.write(`event: update_major_available\ndata: ${JSON.stringify({ version: result.remoteVersion })}\n\n`); } catch { }
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
}).catch(() => { });
|
|
3206
|
+
}, 3000);
|
|
3207
|
+
}).catch(err => {
|
|
3208
|
+
console.error('Failed to start CX Viewer:', err);
|
|
3209
|
+
});
|
|
3210
|
+
});
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
// 进程退出时,将未决的临时文件转为正式文件
|
|
3214
|
+
function handleExit() {
|
|
3215
|
+
if (_resumeState && _resumeState.tempFile) {
|
|
3216
|
+
try {
|
|
3217
|
+
if (existsSync(_resumeState.tempFile)) {
|
|
3218
|
+
const newPath = _resumeState.tempFile.replace('_temp.jsonl', '.jsonl');
|
|
3219
|
+
renameSync(_resumeState.tempFile, newPath);
|
|
3220
|
+
}
|
|
3221
|
+
} catch { }
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
process.on('exit', handleExit);
|
|
3225
|
+
process.on('SIGINT', () => { stopViewer().finally(() => process.exit()); });
|
|
3226
|
+
process.on('SIGTERM', () => { stopViewer().finally(() => process.exit()); });
|