@yancyyu/openhermit 1.6.41 → 1.6.43
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/README.md +98 -89
- package/bin/hermit.mjs +96 -0
- package/dist-renderer/assets/{ProjectEditorOverlay-Br0X83Jf.js → ProjectEditorOverlay-C98qSs7-.js} +1 -1
- package/dist-renderer/assets/{TeamGraphOverlay-DHMTbZPZ.js → TeamGraphOverlay-CsBbZwcL.js} +1 -1
- package/dist-renderer/assets/{_basePickBy-DzIiX7yH.js → _basePickBy-ZOyLWjMK.js} +1 -1
- package/dist-renderer/assets/{_baseUniq-6hZuzTLU.js → _baseUniq-DBb726rt.js} +1 -1
- package/dist-renderer/assets/{arc-CXgO6fx_.js → arc-CdiTaR_R.js} +1 -1
- package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-DKWgtDHr.js → architectureDiagram-VXUJARFQ-Cz3sc5TH.js} +1 -1
- package/dist-renderer/assets/{blockDiagram-VD42YOAC-DOMUcC40.js → blockDiagram-VD42YOAC-DE4c-KJ3.js} +1 -1
- package/dist-renderer/assets/{c4Diagram-YG6GDRKO-B_k2L7qX.js → c4Diagram-YG6GDRKO-CmTMDTrV.js} +1 -1
- package/dist-renderer/assets/channel-KTpqi9eT.js +1 -0
- package/dist-renderer/assets/{chunk-4BX2VUAB-BeD_ccFy.js → chunk-4BX2VUAB-rhHy3tFl.js} +1 -1
- package/dist-renderer/assets/{chunk-55IACEB6-ClZfkA5w.js → chunk-55IACEB6-fLZBzuo_.js} +1 -1
- package/dist-renderer/assets/{chunk-B4BG7PRW-5XluxXsn.js → chunk-B4BG7PRW-DOzxQhim.js} +1 -1
- package/dist-renderer/assets/{chunk-DI55MBZ5-BzIjjNVm.js → chunk-DI55MBZ5-COQCcXC5.js} +1 -1
- package/dist-renderer/assets/{chunk-FMBD7UC4-HgH3MK_H.js → chunk-FMBD7UC4-IKU9U_Y4.js} +1 -1
- package/dist-renderer/assets/{chunk-QN33PNHL-WeC5T3Ba.js → chunk-QN33PNHL-D6WV154X.js} +1 -1
- package/dist-renderer/assets/{chunk-QZHKN3VN-Cu1ApHfW.js → chunk-QZHKN3VN-D90_2DQp.js} +1 -1
- package/dist-renderer/assets/{chunk-TZMSLE5B-BOhlynJM.js → chunk-TZMSLE5B-BQEil57G.js} +1 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-lpzulY5X.js +1 -0
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-lpzulY5X.js +1 -0
- package/dist-renderer/assets/clone-CriGymY9.js +1 -0
- package/dist-renderer/assets/{cose-bilkent-S5V4N54A-DGZSihDQ.js → cose-bilkent-S5V4N54A-6WiK6U2P.js} +1 -1
- package/dist-renderer/assets/{dagre-6UL2VRFP-CnxwCbku.js → dagre-6UL2VRFP-DF4MMuTn.js} +1 -1
- package/dist-renderer/assets/{diagram-PSM6KHXK-DsIhoxdI.js → diagram-PSM6KHXK-CcF1eZ7E.js} +1 -1
- package/dist-renderer/assets/{diagram-QEK2KX5R-Cmh9KUF5.js → diagram-QEK2KX5R-DYlOVPQB.js} +1 -1
- package/dist-renderer/assets/{diagram-S2PKOQOG-CKxV456A.js → diagram-S2PKOQOG-BHXWsZOP.js} +1 -1
- package/dist-renderer/assets/{erDiagram-Q2GNP2WA-EnvYjOjc.js → erDiagram-Q2GNP2WA-GjmuBx8d.js} +1 -1
- package/dist-renderer/assets/{flowDiagram-NV44I4VS-BmNeWY_A.js → flowDiagram-NV44I4VS-BuS7YVHk.js} +1 -1
- package/dist-renderer/assets/{ganttDiagram-JELNMOA3-D30fyK-u.js → ganttDiagram-JELNMOA3-3Teu5tAa.js} +1 -1
- package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-CrUNiYg1.js → gitGraphDiagram-V2S2FVAM-BiLdCYu5.js} +1 -1
- package/dist-renderer/assets/{graph-CY1gTfTb.js → graph-CDP_R8ct.js} +1 -1
- package/dist-renderer/assets/{index-CaEbzwAU.js → index-BSZdT-g-.js} +1 -1
- package/dist-renderer/assets/{index-D5K-SjBG.js → index-BhWvMqsz.js} +1 -1
- package/dist-renderer/assets/{index-9_hO4N1e.js → index-C2_AupSj.js} +1 -1
- package/dist-renderer/assets/{index-59r209c1.js → index-C5ujiwAR.js} +580 -588
- package/dist-renderer/assets/index-CIS2CTK9.css +1 -0
- package/dist-renderer/assets/{index-DMR9B1UP.js → index-CVNjLwkq.js} +1 -1
- package/dist-renderer/assets/{index-BC2hXmg_.js → index-CwG3se0q.js} +1 -1
- package/dist-renderer/assets/{infoDiagram-HS3SLOUP-By_XUlcD.js → infoDiagram-HS3SLOUP-DLHUFo72.js} +1 -1
- package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-BM1LJE9m.js → journeyDiagram-XKPGCS4Q-BE07RpJD.js} +1 -1
- package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-DHIW3aTA.js → kanban-definition-3W4ZIXB7-DDHZy4NB.js} +1 -1
- package/dist-renderer/assets/{layout-DAKiL_Mo.js → layout-5nA5wUxO.js} +1 -1
- package/dist-renderer/assets/{linear-DwOaRYea.js → linear-BtF1i2qN.js} +1 -1
- package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-b7bJ2cha.js → mindmap-definition-VGOIOE7T-Z1Ui9Sqy.js} +1 -1
- package/dist-renderer/assets/{pieDiagram-ADFJNKIX-DxyL9Zr2.js → pieDiagram-ADFJNKIX-LCjxckWv.js} +1 -1
- package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-CR33pHlF.js → quadrantDiagram-AYHSOK5B-BOwKjSco.js} +1 -1
- package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-BAiSRSlh.js → requirementDiagram-UZGBJVZJ-pChP8Znd.js} +1 -1
- package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-C8JmDjoa.js → sankeyDiagram-TZEHDZUN-DifZ2qpo.js} +1 -1
- package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-c1d0Wi1m.js → sequenceDiagram-WL72ISMW-CJg-WYyY.js} +1 -1
- package/dist-renderer/assets/{splashScene-D0YB9uxm.js → splashScene-94xWCzLA.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-nT8BiH2O.js → stateDiagram-FKZM4ZOC-DWHOoFdv.js} +1 -1
- package/dist-renderer/assets/stateDiagram-v2-4FDKWEC3-CGYZOoMb.js +1 -0
- package/dist-renderer/assets/{timeline-definition-IT6M3QCI-DpoRepUA.js → timeline-definition-IT6M3QCI-CPgokIo8.js} +1 -1
- package/dist-renderer/assets/{treemap-GDKQZRPO-C41UJeIH.js → treemap-GDKQZRPO-DAVqSR9L.js} +1 -1
- package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-KMjGARKN.js → xychartDiagram-PRI3JC2R-CCOcGbrD.js} +1 -1
- package/dist-renderer/chat-community-qr.jpg +0 -0
- package/dist-renderer/fonts/Agave-Bold.ttf +0 -0
- package/dist-renderer/fonts/Agave-Regular.ttf +0 -0
- package/dist-renderer/icon.png +0 -0
- package/dist-renderer/icon.rar +0 -0
- package/dist-renderer/index.html +3 -3
- package/package.json +21 -26
- package/src/features/worker-society/core/application/WorkerSocietyService.test.ts +802 -0
- package/src/features/worker-society/core/application/WorkerSocietyService.ts +428 -0
- package/src/features/worker-society/core/application/fakes.ts +101 -0
- package/src/features/worker-society/core/application/ports.ts +70 -0
- package/src/features/worker-society/core/domain/models/society.ts +141 -0
- package/src/features/worker-society/core/domain/policies/societyPolicies.test.ts +739 -0
- package/src/features/worker-society/core/domain/policies/societyPolicies.ts +496 -0
- package/src/features/worker-society/main/adapters/input/societyMcp.test.ts +317 -0
- package/src/features/worker-society/main/adapters/input/societyMcp.ts +257 -0
- package/src/features/worker-society/main/adapters/input/societyRoutes.test.ts +695 -0
- package/src/features/worker-society/main/adapters/input/societyRoutes.ts +194 -0
- package/src/features/worker-society/main/composition/societyComposition.test.ts +74 -0
- package/src/features/worker-society/main/composition/societyComposition.ts +70 -0
- package/src/features/worker-society/main/composition/workerSocietyPlugin.test.ts +69 -0
- package/src/features/worker-society/main/composition/workerSocietyPlugin.ts +67 -0
- package/src/features/worker-society/main/infrastructure/crossTeamMessageGateway.test.ts +132 -0
- package/src/features/worker-society/main/infrastructure/crossTeamMessageGateway.ts +84 -0
- package/src/features/worker-society/main/infrastructure/fsStores.test.ts +216 -0
- package/src/features/worker-society/main/infrastructure/fsStores.ts +113 -0
- package/src/features/worker-society/main/infrastructure/mergingProfileStore.test.ts +195 -0
- package/src/features/worker-society/main/infrastructure/mergingProfileStore.ts +96 -0
- package/src/features/worker-society/renderer/SocietyGraph.tsx +166 -0
- package/src/features/worker-society/renderer/SocietyNodeLabels.tsx +139 -0
- package/src/features/worker-society/renderer/SocietyNodeOverlay.tsx +339 -0
- package/src/features/worker-society/renderer/SocietyView.tsx +437 -0
- package/src/features/worker-society/renderer/index.ts +11 -0
- package/src/features/worker-society/renderer/societyApi.test.ts +259 -0
- package/src/features/worker-society/renderer/societyApi.ts +144 -0
- package/src/features/worker-society/renderer/societyGraphAdapter.test.ts +321 -0
- package/src/features/worker-society/renderer/societyGraphAdapter.ts +240 -0
- package/src/features/worker-society/renderer/societyOverlayActions.test.ts +57 -0
- package/src/features/worker-society/renderer/societyOverlayActions.ts +49 -0
- package/src/features/worker-society/renderer/societyStore.test.ts +218 -0
- package/src/features/worker-society/renderer/societyStore.ts +146 -0
- package/src/features/worker-society/renderer/societyViewUtils.test.ts +81 -0
- package/src/features/worker-society/renderer/societyViewUtils.ts +68 -0
- package/src/main/ipc/extensions.ts +27 -0
- package/src/main/server.ts +1731 -539
- package/src/main/services/ccConnect/CcConnectBridge.ts +26 -11
- package/src/main/services/ccConnect/CcConnectClient.ts +9 -2
- package/src/main/services/ccConnect/workDirReconcile.test.ts +57 -0
- package/src/main/services/ccConnect/workDirReconcile.ts +36 -0
- package/src/main/services/direct-cli/DirectCliSessionManager.test.ts +397 -0
- package/src/main/services/direct-cli/DirectCliSessionManager.ts +508 -0
- package/src/main/services/direct-cli/DirectCliSessionStore.test.ts +79 -0
- package/src/main/services/direct-cli/DirectCliSessionStore.ts +97 -0
- package/src/main/services/direct-cli/__tests__/directCliMessageId.test.ts +40 -0
- package/src/main/services/direct-cli/directCliMessageId.ts +21 -0
- package/src/main/services/direct-cli/index.ts +17 -0
- package/src/main/services/extensions/capability-packs/CapabilityPackLoaderService.ts +637 -0
- package/src/main/services/extensions/catalog/PluginCatalogService.ts +2 -2
- package/src/main/services/loop-assets/LoopAssetsScannerService.ts +657 -0
- package/src/main/services/runtime/providerAwareCliEnv.ts +33 -5
- package/src/main/services/session-intelligence/LocalSessionScanner.ts +156 -71
- package/src/main/services/session-intelligence/SessionUsageParser.ts +103 -8
- package/src/main/services/session-intelligence/UsageTelemetryService.ts +11 -0
- package/src/main/services/session-intelligence/__tests__/teamSessionListMapper.test.ts +104 -0
- package/src/main/services/session-intelligence/teamSessionListMapper.ts +78 -0
- package/src/main/services/system-manager/AdminLoopInitializer.ts +95 -0
- package/src/main/services/system-manager/BuiltinWorkflowSeeder.ts +744 -0
- package/src/main/services/system-manager/SystemManagerConfigService.ts +19 -1
- package/src/main/services/system-manager/WorkflowPromptService.ts +58 -5
- package/src/main/services/system-manager/__tests__/AdminLoopInitializer.test.ts +129 -0
- package/src/main/services/system-manager/__tests__/SystemManagerConfigService.test.ts +60 -0
- package/src/main/services/teams-mvp/CollaborationBoardService.ts +2 -0
- package/src/main/services/teams-mvp/OpsRunbookContext.ts +60 -0
- package/src/main/services/teams-mvp/TaskDispatchService.test.ts +305 -0
- package/src/main/services/teams-mvp/TaskDispatchService.ts +250 -131
- package/src/main/services/teams-mvp/TeamProvisioningService.ts +12 -2
- package/src/main/services/teams-mvp/TeamWorkspaceService.test.ts +207 -0
- package/src/main/services/teams-mvp/TeamWorkspaceService.ts +104 -51
- package/src/main/services/teams-mvp/index.ts +6 -0
- package/src/main/utils/externalPlatformSessionRouting.ts +92 -0
- package/src/main/utils/toolApprovalRules.ts +151 -0
- package/src/renderer/App.tsx +24 -89
- package/src/renderer/api/httpClient.ts +132 -52
- package/src/renderer/api/providers.ts +5 -16
- package/src/renderer/components/chat/CommunityChatView.tsx +81 -0
- package/src/renderer/components/dashboard/DashboardView.tsx +130 -84
- package/src/renderer/components/extensions/ExtensionStoreView.tsx +39 -5
- package/src/renderer/components/extensions/ExtensionsSubTabTrigger.tsx +2 -1
- package/src/renderer/components/extensions/capability-packs/CapabilityPacksPanel.tsx +170 -0
- package/src/renderer/components/layout/PaneContent.tsx +10 -2
- package/src/renderer/components/layout/SortableTab.tsx +4 -0
- package/src/renderer/components/layout/TabBarActions.tsx +13 -16
- package/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +4 -135
- package/src/renderer/components/schedules/SchedulesView.tsx +22 -14
- package/src/renderer/components/schedules/calendar/CalendarEventBlock.tsx +7 -6
- package/src/renderer/components/settings/SettingsTabs.tsx +24 -21
- package/src/renderer/components/settings/SettingsView.tsx +22 -13
- package/src/renderer/components/settings/components/SettingRow.tsx +13 -5
- package/src/renderer/components/settings/components/SettingsSectionCard.tsx +53 -0
- package/src/renderer/components/settings/components/SettingsSectionHeader.tsx +10 -6
- package/src/renderer/components/settings/components/SettingsSelect.tsx +12 -9
- package/src/renderer/components/settings/components/SettingsToggle.tsx +6 -5
- package/src/renderer/components/settings/components/index.ts +1 -0
- package/src/renderer/components/settings/sections/AdvancedSection.tsx +78 -59
- package/src/renderer/components/settings/sections/CliStatusSection.tsx +32 -44
- package/src/renderer/components/settings/sections/ConfigEditorDialog.tsx +1 -1
- package/src/renderer/components/settings/sections/GeneralSection.tsx +216 -186
- package/src/renderer/components/settings/sections/PlatformsSection.tsx +25 -17
- package/src/renderer/components/settings/sections/TaskBusSection.tsx +63 -22
- package/src/renderer/components/sidebar/SidebarSessions.tsx +120 -80
- package/src/renderer/components/sidebar/SidebarTaskItem.tsx +1 -1
- package/src/renderer/components/splash/splashScene.ts +6 -2
- package/src/renderer/components/system-manager/SystemManagerView.tsx +169 -255
- package/src/renderer/components/tasks/TasksView.tsx +63 -37
- package/src/renderer/components/team/CcSessionsSection.tsx +124 -89
- package/src/renderer/components/team/HarnessBrandLogos.tsx +318 -0
- package/src/renderer/components/team/HarnessSelect.tsx +25 -26
- package/src/renderer/components/team/TeamDetailView.tsx +137 -153
- package/src/renderer/components/team/TeamEmptyState.tsx +9 -37
- package/src/renderer/components/team/TeamListView.tsx +144 -30
- package/src/renderer/components/team/__tests__/CcSessionsSection.hasLocalFile.test.tsx +128 -0
- package/src/renderer/components/team/activity/ActivityItem.tsx +21 -9
- package/src/renderer/components/team/activity/ActivityTimeline.tsx +2 -2
- package/src/renderer/components/team/dialogs/AdvancedCliSection.tsx +1 -1
- package/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +13 -10
- package/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +189 -57
- package/src/renderer/components/team/dialogs/EditTeamDialog.tsx +9 -157
- package/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +19 -15
- package/src/renderer/components/team/dialogs/PlatformBindingDialog.tsx +48 -10
- package/src/renderer/components/team/dialogs/PlatformManualForm.tsx +11 -12
- package/src/renderer/components/team/dialogs/PlatformSetupQR.tsx +39 -37
- package/src/renderer/components/team/dialogs/RuntimeConfigDialog.tsx +434 -64
- package/src/renderer/components/team/dialogs/SendMessageDialog.tsx +12 -10
- package/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +2 -2
- package/src/renderer/components/team/dialogs/__tests__/CreateTeamDialog.bindProject.test.tsx +399 -0
- package/src/renderer/components/team/dialogs/__tests__/CreateTeamDialog.chineseRepro.test.tsx +253 -0
- package/src/renderer/components/team/dialogs/platformAllowUtils.ts +91 -0
- package/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx +1 -1
- package/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +41 -0
- package/src/renderer/components/team/kanban/KanbanTaskCard.tsx +41 -86
- package/src/renderer/components/team/loop-console/LoopCommandComposer.tsx +310 -0
- package/src/renderer/components/team/loop-console/LoopConsolePanel.tsx +372 -0
- package/src/renderer/components/team/loop-console/loopSendIntent.test.ts +85 -0
- package/src/renderer/components/team/loop-console/loopSendIntent.ts +221 -0
- package/src/renderer/components/team/loop-console/useLeadSessionToolActivity.ts +74 -0
- package/src/renderer/components/team/loop-console/useLoopCommandSuggestions.ts +165 -0
- package/src/renderer/components/team/loop-console/useLoopConsoleController.ts +266 -0
- package/src/renderer/components/team/members/LeadModelRow.test.tsx +1 -1
- package/src/renderer/components/team/members/LeadModelRow.tsx +5 -3
- package/src/renderer/components/team/members/MemberDetailDialog.tsx +11 -0
- package/src/renderer/components/team/members/MemberDetailStats.tsx +13 -3
- package/src/renderer/components/team/members/MemberDraftRow.test.tsx +1 -1
- package/src/renderer/components/team/members/MemberDraftRow.tsx +1 -1
- package/src/renderer/components/team/members/MemberMessagesTab.tsx +2 -2
- package/src/renderer/components/team/members/MemberStatsTab.tsx +1 -1
- package/src/renderer/components/team/messages/MessageComposer.tsx +150 -44
- package/src/renderer/components/team/messages/MessagesFilterPopover.tsx +2 -2
- package/src/renderer/components/team/messages/MessagesPanel.tsx +34 -28
- package/src/renderer/components/team/schedule/CcCronScheduleDialog.tsx +6 -6
- package/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx +2 -2
- package/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +1 -1
- package/src/renderer/components/terminal/TerminalPanel.tsx +2 -3
- package/src/renderer/components/ui/MentionableTextarea.tsx +5 -1
- package/src/renderer/constants/teamColors.ts +5 -5
- package/src/renderer/hooks/useExtensionsTabState.ts +1 -1
- package/src/renderer/hooks/useMentionDetection.ts +5 -1
- package/src/renderer/hooks/useProjectWorkflowCommands.ts +57 -0
- package/src/renderer/hooks/useTeamSuggestions.ts +2 -0
- package/src/renderer/index.css +19 -2
- package/src/renderer/main.tsx +7 -1
- package/src/renderer/store/index.ts +18 -1
- package/src/renderer/store/slices/extensionsSlice.ts +83 -0
- package/src/renderer/store/slices/tabSlice.ts +61 -0
- package/src/renderer/store/slices/teamSlice.ts +138 -9
- package/src/renderer/types/mention.ts +8 -0
- package/src/renderer/types/tabs.ts +3 -1
- package/src/renderer/utils/__tests__/bindProjectSlug.test.ts +69 -0
- package/src/renderer/utils/__tests__/groupTransformer.test.ts +148 -0
- package/src/renderer/utils/__tests__/initialRoute.test.ts +101 -0
- package/src/renderer/utils/__tests__/leadToolActivity.test.ts +124 -0
- package/src/renderer/utils/__tests__/mergeTeamMessages.test.ts +81 -0
- package/src/renderer/utils/__tests__/teamMessageFiltering.test.ts +213 -0
- package/src/renderer/utils/__tests__/teamMessageKey.test.ts +75 -0
- package/src/renderer/utils/__tests__/workflowCommandExecution.test.ts +173 -0
- package/src/renderer/utils/__tests__/workflowCommandSuggestions.test.ts +59 -0
- package/src/renderer/utils/bindProjectSlug.ts +57 -0
- package/src/renderer/utils/capabilityCommandExecution.ts +113 -0
- package/src/renderer/utils/initialRoute.ts +89 -0
- package/src/renderer/utils/leadToolActivity.ts +117 -0
- package/src/renderer/utils/loopShortcutSuggestions.ts +106 -0
- package/src/renderer/utils/mentionSuggestions.ts +1 -1
- package/src/renderer/utils/slashCommandRegistry.ts +231 -0
- package/src/renderer/utils/teamMentionDirective.ts +31 -0
- package/src/renderer/utils/workflowCommandExecution.ts +96 -0
- package/src/renderer/utils/workflowCommandSuggestions.ts +49 -0
- package/src/shared/types/api.ts +79 -4
- package/src/shared/types/ccConnect.ts +1 -0
- package/src/shared/types/extensions/api.ts +19 -0
- package/src/shared/types/extensions/capabilityPack.ts +118 -0
- package/src/shared/types/extensions/index.ts +29 -1
- package/src/shared/types/index.ts +6 -0
- package/src/shared/types/loopAssets.ts +54 -0
- package/src/shared/types/providers.ts +0 -16
- package/src/shared/types/systemManager.ts +26 -1
- package/src/shared/types/team.ts +43 -3
- package/src/shared/types/terminal.ts +2 -36
- package/src/shared/types/worker.test.ts +28 -0
- package/src/shared/types/worker.ts +3 -0
- package/src/shared/utils/__tests__/effortLevels.test.ts +88 -0
- package/src/shared/utils/__tests__/providerBackend.test.ts +88 -0
- package/src/shared/utils/__tests__/providerLaunchArgs.test.ts +220 -0
- package/src/shared/utils/claudeStreamJson.test.ts +187 -0
- package/src/shared/utils/claudeStreamJson.ts +153 -0
- package/src/shared/utils/providerLaunchArgs.ts +217 -0
- package/src/shared/utils/slashCommands.ts +10 -0
- package/src/types/node-pty.d.ts +8 -0
- package/dist-renderer/assets/channel-D0XS_akr.js +0 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-D13Ffs0U.js +0 -1
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-D13Ffs0U.js +0 -1
- package/dist-renderer/assets/clone-B1ZrxI1D.js +0 -1
- package/dist-renderer/assets/index-iyjkpSus.css +0 -32
- package/dist-renderer/assets/stateDiagram-v2-4FDKWEC3-Dmibmlso.js +0 -1
- package/src/main/services/system-manager/SystemManagerPtyService.ts +0 -233
- package/src/renderer/components/common/TerminalPane.tsx +0 -213
- package/src/renderer/components/team/dialogs/useTeamEditForm.ts +0 -292
package/src/main/server.ts
CHANGED
|
@@ -44,27 +44,53 @@ import staticPlugin from '@fastify/static';
|
|
|
44
44
|
import { Cron } from 'croner';
|
|
45
45
|
import Fastify from 'fastify';
|
|
46
46
|
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
CROSS_TEAM_SOURCE,
|
|
50
|
-
formatCrossTeamText,
|
|
51
|
-
} from '@shared/constants/crossTeam';
|
|
52
|
-
import type { CcAgentType, CcProjectPlatform } from '../shared/types/ccConnect';
|
|
47
|
+
import { CROSS_TEAM_SENT_SOURCE } from '@shared/constants/crossTeam';
|
|
48
|
+
import type { CcAgentType, CcProjectPlatform, CcSessionListItem } from '../shared/types/ccConnect';
|
|
53
49
|
import { CcConnectBridge } from './services/ccConnect/CcConnectBridge';
|
|
54
50
|
import { CcConnectClient } from './services/ccConnect/CcConnectClient';
|
|
51
|
+
import { isPlaceholderWorkDir, needsWorkDirReconcile } from './services/ccConnect/workDirReconcile';
|
|
52
|
+
import {
|
|
53
|
+
DirectCliSessionManager,
|
|
54
|
+
buildDirectReplyMessageId,
|
|
55
|
+
type DirectCliEvent,
|
|
56
|
+
} from './services/direct-cli';
|
|
55
57
|
import { TeamProvisioningService } from './services/teams-mvp';
|
|
56
58
|
import { TaskDispatchService } from './services/teams-mvp/TaskDispatchService';
|
|
57
59
|
import { resolveCcProjectName } from './utils/teamProjectResolution';
|
|
58
60
|
import { CollaborationBoardService } from './services/teams-mvp/CollaborationBoardService';
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
61
|
+
import { createWorkerSociety } from '@features/worker-society/main/composition/societyComposition';
|
|
62
|
+
import { registerSocietyRoutes } from '@features/worker-society/main/adapters/input/societyRoutes';
|
|
63
|
+
import {
|
|
64
|
+
SOCIETY_MCP_TOOLS,
|
|
65
|
+
executeSocietyMcpTool,
|
|
66
|
+
} from '@features/worker-society/main/adapters/input/societyMcp';
|
|
67
|
+
import {
|
|
68
|
+
SystemManagerConfigService,
|
|
69
|
+
adminWorkDir,
|
|
70
|
+
} from './services/system-manager/SystemManagerConfigService';
|
|
71
|
+
import { ensureAdminLoopInitialized as runAdminLoopInit } from './services/system-manager/AdminLoopInitializer';
|
|
72
|
+
import { httpsGetFollowRedirects } from './services/extensions/catalog/PluginCatalogService';
|
|
73
|
+
import { HERMIT_OPS_GUIDE_URL } from './services/teams-mvp/OpsRunbookContext';
|
|
61
74
|
import { WorkflowPromptService } from './services/system-manager/WorkflowPromptService';
|
|
75
|
+
import {
|
|
76
|
+
ensureGlobalWorkflows,
|
|
77
|
+
seedBuiltinWorkflows,
|
|
78
|
+
} from './services/system-manager/BuiltinWorkflowSeeder';
|
|
62
79
|
import {
|
|
63
80
|
SYSTEM_MANAGER_BIND_PROJECT,
|
|
64
81
|
SYSTEM_MANAGER_DISPLAY_NAME,
|
|
65
82
|
SYSTEM_MANAGER_TEAM_NAME,
|
|
66
83
|
} from '@shared/types/team';
|
|
67
|
-
import type {
|
|
84
|
+
import type {
|
|
85
|
+
SystemManagerSummary,
|
|
86
|
+
TaskBusConfig,
|
|
87
|
+
TeamLaunchRequest,
|
|
88
|
+
ToolApprovalSettings,
|
|
89
|
+
ToolApprovalRequest,
|
|
90
|
+
ToolApprovalAutoResolved,
|
|
91
|
+
} from '@shared/types/team';
|
|
92
|
+
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
|
93
|
+
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
|
68
94
|
import type { TeamManifest } from './services/teams-mvp/TeamWorkspaceService';
|
|
69
95
|
import { UpdateService } from './services/UpdateService';
|
|
70
96
|
import {
|
|
@@ -78,6 +104,18 @@ import {
|
|
|
78
104
|
shouldIncludeContent,
|
|
79
105
|
} from './services/session-intelligence/ConversationTelemetryService';
|
|
80
106
|
import { LocalSessionScanner } from './services/session-intelligence/LocalSessionScanner';
|
|
107
|
+
import { mergeLocalAndCcSessions } from './services/session-intelligence/teamSessionListMapper';
|
|
108
|
+
import type { CcSession } from '@shared/types/api';
|
|
109
|
+
import { discoverableTeamToWorker, type DiscoverableWorker } from '@shared/types/worker';
|
|
110
|
+
import { LoopAssetsScannerService } from './services/loop-assets/LoopAssetsScannerService';
|
|
111
|
+
import {
|
|
112
|
+
scanProjectStats,
|
|
113
|
+
type ProjectUsageStats,
|
|
114
|
+
} from './services/session-intelligence/SessionUsageParser';
|
|
115
|
+
import {
|
|
116
|
+
isExternalPlatformSessionKey,
|
|
117
|
+
resolveExternalPlatformSessionTeamSlug,
|
|
118
|
+
} from './utils/externalPlatformSessionRouting';
|
|
81
119
|
|
|
82
120
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
83
121
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'));
|
|
@@ -102,7 +140,7 @@ const CC_AGENT_TYPES: readonly CcAgentType[] = [
|
|
|
102
140
|
'tmux',
|
|
103
141
|
];
|
|
104
142
|
const SYSTEM_MANAGER_DESCRIPTION =
|
|
105
|
-
'项目级 Claude Code
|
|
143
|
+
'项目级 Claude Code Helm Loop,负责插件、MCP、Env、数字员工和统计数据的托管管理。';
|
|
106
144
|
|
|
107
145
|
function toCcAgentType(value: string | undefined): CcAgentType {
|
|
108
146
|
return CC_AGENT_TYPES.includes(value as CcAgentType) ? (value as CcAgentType) : 'claudecode';
|
|
@@ -188,9 +226,10 @@ function loadConfig(): HermitConfig {
|
|
|
188
226
|
merged = { ...defaults, ...raw };
|
|
189
227
|
}
|
|
190
228
|
} catch (err) {
|
|
191
|
-
const msg =
|
|
192
|
-
|
|
193
|
-
|
|
229
|
+
const msg =
|
|
230
|
+
err instanceof SyntaxError
|
|
231
|
+
? `${HERMIT_CONFIG_FILE} 格式错误: ${err.message}。将使用默认配置并覆盖修复。`
|
|
232
|
+
: `读取 ${HERMIT_CONFIG_FILE} 失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
194
233
|
console.warn(`[Hermit] ${msg}`);
|
|
195
234
|
// Auto-heal: rewrite the config file with valid defaults + any readable env overrides
|
|
196
235
|
mkdirSync(HERMIT_HOME, { recursive: true });
|
|
@@ -229,7 +268,9 @@ function writeHermitConfigRaw(content: string): HermitConfig {
|
|
|
229
268
|
parsed = JSON.parse(content);
|
|
230
269
|
} catch (err) {
|
|
231
270
|
if (err instanceof SyntaxError) {
|
|
232
|
-
throw new Error(
|
|
271
|
+
throw new Error(
|
|
272
|
+
`配置文件 JSON 格式错误: ${err.message}。请检查是否有尾逗号、单引号或注释等非法 JSON 语法。`
|
|
273
|
+
);
|
|
233
274
|
}
|
|
234
275
|
throw err;
|
|
235
276
|
}
|
|
@@ -254,31 +295,21 @@ const bridge = new CcConnectBridge({
|
|
|
254
295
|
bridgeUrl: runtimeConfig.ccBridgeUrl,
|
|
255
296
|
bridgeToken: runtimeConfig.ccBridgeToken || runtimeConfig.ccToken,
|
|
256
297
|
});
|
|
257
|
-
const svc = new TeamProvisioningService(cc, bridge
|
|
298
|
+
const svc = new TeamProvisioningService(cc, bridge, undefined, {
|
|
299
|
+
restartCcConnect: restartCcConnectAndReconnectBridge,
|
|
300
|
+
});
|
|
258
301
|
const systemManagerConfig = new SystemManagerConfigService(REPO_ROOT);
|
|
259
|
-
const systemManagerPty = new SystemManagerPtyService();
|
|
260
302
|
const workflowPromptService = new WorkflowPromptService();
|
|
261
303
|
|
|
262
|
-
systemManagerPty.on('data', (event) => broadcastSse('terminal:data', event));
|
|
263
|
-
systemManagerPty.on('exit', (event) => broadcastSse('terminal:exit', event));
|
|
264
|
-
|
|
265
304
|
async function getSystemManagerWorkDir(): Promise<string> {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
const manifest = await svc.readTeamManifest(SYSTEM_MANAGER_TEAM_NAME);
|
|
276
|
-
if (manifest.workDir !== workDir) {
|
|
277
|
-
await svc.updateTeam(SYSTEM_MANAGER_TEAM_NAME, { workDir });
|
|
278
|
-
}
|
|
279
|
-
} catch {
|
|
280
|
-
// The console team may not exist yet; ensureSystemManager() will create it later.
|
|
281
|
-
}
|
|
305
|
+
// Canonical, isolated Helm Loop runtime path. Dedicated (never shared with
|
|
306
|
+
// another team/project) so the admin agent can bootstrap its own CLAUDE.md
|
|
307
|
+
// here without colliding with project work. The manifest workDir is synced to
|
|
308
|
+
// this path by ensureSystemManagerUncached; selectedWorkDir only drives
|
|
309
|
+
// workflow-command discovery, not the runtime location.
|
|
310
|
+
const dir = adminWorkDir();
|
|
311
|
+
await fs.mkdir(dir, { recursive: true }).catch(() => undefined);
|
|
312
|
+
return dir;
|
|
282
313
|
}
|
|
283
314
|
|
|
284
315
|
let systemManagerEnsurePromise: Promise<SystemManagerSummary> | null = null;
|
|
@@ -348,52 +379,142 @@ async function ensureSystemManager(): Promise<SystemManagerSummary> {
|
|
|
348
379
|
return systemManagerEnsurePromise;
|
|
349
380
|
}
|
|
350
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Helm Loop bootstrap wrapper. On first open, fetch the ops guide and feed it to
|
|
384
|
+
* the admin lead session as the first turn so the agent seeds its own CLAUDE.md.
|
|
385
|
+
* Idempotent + failure-retrying (see AdminLoopInitializer). The bootstrap user
|
|
386
|
+
* message is also appended to the team inbox so it is visible in the console.
|
|
387
|
+
* Invoked fire-and-forget from the ensure endpoint — never blocks open.
|
|
388
|
+
*/
|
|
389
|
+
async function ensureAdminLoopInitialized(): Promise<void> {
|
|
390
|
+
const sessionKey = `${SYSTEM_MANAGER_TEAM_NAME}:lead`;
|
|
391
|
+
await runAdminLoopInit({
|
|
392
|
+
getConfig: () => systemManagerConfig.getConfig(),
|
|
393
|
+
updateConfig: (patch) => systemManagerConfig.updateConfig(patch),
|
|
394
|
+
fetchGuide: () => httpsGetFollowRedirects(HERMIT_OPS_GUIDE_URL),
|
|
395
|
+
log: (message) => app.log.warn({ sessionKey }, message),
|
|
396
|
+
dispatch: async ({ text, messageId }) => {
|
|
397
|
+
const workDir = await getSystemManagerWorkDir();
|
|
398
|
+
await svc
|
|
399
|
+
.appendMessage(SYSTEM_MANAGER_TEAM_NAME, {
|
|
400
|
+
from: 'user',
|
|
401
|
+
to: SYSTEM_MANAGER_TEAM_NAME,
|
|
402
|
+
role: 'user',
|
|
403
|
+
content: text,
|
|
404
|
+
meta: { sessionKey, source: 'admin-init' },
|
|
405
|
+
})
|
|
406
|
+
.catch((err) =>
|
|
407
|
+
app.log.warn({ err, sessionKey }, 'helm loop init: append user message failed')
|
|
408
|
+
);
|
|
409
|
+
await dispatchDirectCliMessage({
|
|
410
|
+
teamName: SYSTEM_MANAGER_TEAM_NAME,
|
|
411
|
+
sessionKey,
|
|
412
|
+
workDir,
|
|
413
|
+
from: SYSTEM_MANAGER_TEAM_NAME,
|
|
414
|
+
to: 'user',
|
|
415
|
+
text,
|
|
416
|
+
messageId,
|
|
417
|
+
});
|
|
418
|
+
broadcastSse('team-change', { type: 'inbox', teamName: SYSTEM_MANAGER_TEAM_NAME });
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
351
423
|
const conversationTelemetry = new ConversationTelemetryService({
|
|
352
424
|
cc,
|
|
353
425
|
listTeams: () => svc.listTeams(),
|
|
354
426
|
readTeamManifest: (teamName) => svc.readTeamManifest(teamName),
|
|
355
427
|
});
|
|
356
428
|
const localSessionScanner = new LocalSessionScanner();
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
let tokens = 0;
|
|
366
|
-
let messages = 0;
|
|
367
|
-
let earliest: string | null = null;
|
|
368
|
-
let latest: string | null = null;
|
|
369
|
-
for (const s of sessions) {
|
|
370
|
-
tokens += s.inputTokens + s.outputTokens;
|
|
371
|
-
messages += s.messageCount;
|
|
372
|
-
if (s.startTime && (!earliest || s.startTime < earliest)) earliest = s.startTime;
|
|
373
|
-
if (s.endTime && (!latest || s.endTime > latest)) latest = s.endTime;
|
|
374
|
-
}
|
|
375
|
-
let durationMs = 0;
|
|
376
|
-
if (earliest && latest) {
|
|
377
|
-
durationMs = Date.parse(latest) - Date.parse(earliest);
|
|
378
|
-
if (durationMs < 0) durationMs = 0;
|
|
379
|
-
}
|
|
380
|
-
return { sessions: sessions.length, messages, tokens, durationMs };
|
|
381
|
-
} catch {
|
|
382
|
-
return undefined;
|
|
429
|
+
const loopAssetsScanner = new LoopAssetsScannerService();
|
|
430
|
+
const TEAM_STATS_CACHE_TTL_MS = 30_000;
|
|
431
|
+
const teamStatsCache = new Map<
|
|
432
|
+
string,
|
|
433
|
+
{
|
|
434
|
+
expiresAt: number;
|
|
435
|
+
value: ProjectUsageStats | null;
|
|
436
|
+
promise?: Promise<ProjectUsageStats | null>;
|
|
383
437
|
}
|
|
438
|
+
>();
|
|
439
|
+
|
|
440
|
+
function getProjectStatsSnapshot(workDir: string): ProjectUsageStats | null {
|
|
441
|
+
const normalizedWorkDir = workDir.trim();
|
|
442
|
+
if (!normalizedWorkDir) return null;
|
|
443
|
+
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
const cached = teamStatsCache.get(normalizedWorkDir);
|
|
446
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
447
|
+
if (cached?.promise) return cached.value;
|
|
448
|
+
|
|
449
|
+
const promise = scanProjectStats(normalizedWorkDir)
|
|
450
|
+
.catch((err) => {
|
|
451
|
+
app.log.warn({ err, workDir: normalizedWorkDir }, 'scan project stats failed');
|
|
452
|
+
return null;
|
|
453
|
+
})
|
|
454
|
+
.then((value) => {
|
|
455
|
+
teamStatsCache.set(normalizedWorkDir, {
|
|
456
|
+
expiresAt: Date.now() + TEAM_STATS_CACHE_TTL_MS,
|
|
457
|
+
value,
|
|
458
|
+
});
|
|
459
|
+
return value;
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
teamStatsCache.set(normalizedWorkDir, {
|
|
463
|
+
expiresAt: now + TEAM_STATS_CACHE_TTL_MS,
|
|
464
|
+
value: cached?.value ?? null,
|
|
465
|
+
promise,
|
|
466
|
+
});
|
|
467
|
+
void promise;
|
|
468
|
+
return cached?.value ?? null;
|
|
384
469
|
}
|
|
385
470
|
|
|
386
471
|
async function resolveRouteCcProjectName(teamName: string): Promise<string> {
|
|
387
|
-
return resolveCcProjectName(teamName, (name) => svc.
|
|
472
|
+
return resolveCcProjectName(teamName, (name) => svc.readTeamManifestByProject(name));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function restartCcConnectAndReconnectBridge(): Promise<void> {
|
|
476
|
+
await cc.restart();
|
|
477
|
+
|
|
478
|
+
// Wait for cc-connect management API to come back (restart only signals, process respawns async).
|
|
479
|
+
let managementReady = false;
|
|
480
|
+
for (let i = 0; i < 30; i++) {
|
|
481
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
482
|
+
try {
|
|
483
|
+
await cc.listProjects();
|
|
484
|
+
managementReady = true;
|
|
485
|
+
break;
|
|
486
|
+
} catch {
|
|
487
|
+
/* not back yet */
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (!managementReady) {
|
|
491
|
+
throw new Error('cc-connect did not come back within 30s');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// After cc-connect restarts, force Hermit's Bridge adapter to reconnect and re-register.
|
|
495
|
+
// Otherwise Feishu/Lark may show connected in cc-connect but Hermit is not listening yet.
|
|
496
|
+
bridge.reconnect();
|
|
497
|
+
await waitForHarnessBridgeConnected(15_000);
|
|
388
498
|
}
|
|
389
499
|
|
|
390
500
|
const collabBoard = new CollaborationBoardService();
|
|
391
501
|
const taskDispatch = new TaskDispatchService(svc['workspace'], collabBoard);
|
|
392
502
|
|
|
503
|
+
// Worker Society —— 去中心化 worker 自治社交平台(替代派单的主路径)。
|
|
504
|
+
// 状态持久化到 ~/.hermit/society/(声誉/关系/需求/消息跨重启存活);REST 路由见下方 registerSocietyRoutes。
|
|
505
|
+
// 成员花名册以 hermit 真实数字员工为单一事实源:注入 listDiscoverableWorkers(GET /api/workers 同款),
|
|
506
|
+
// 社会层身份即真实团队;能力/声誉/并发由 ~/.hermit/society/profiles.json overlay 叠加(MergingProfileStore)。
|
|
507
|
+
const workerSociety = createWorkerSociety(undefined, {
|
|
508
|
+
realWorkersProvider: listDiscoverableWorkers,
|
|
509
|
+
});
|
|
510
|
+
|
|
393
511
|
// Broadcast collab board changes via SSE
|
|
394
512
|
taskDispatch.onCollabChange = (dispatchId, status, fromTeam, toTeam) => {
|
|
395
513
|
broadcastSse('collab-change', { dispatchId, status, fromTeam, toTeam });
|
|
396
514
|
};
|
|
515
|
+
taskDispatch.onRuntimeStart = async ({ teamName, text }) => {
|
|
516
|
+
await sendHarnessMessageViaBridge({ teamName, text });
|
|
517
|
+
};
|
|
397
518
|
|
|
398
519
|
async function readSavedTaskBusConfig(): Promise<TaskBusConfig | null> {
|
|
399
520
|
try {
|
|
@@ -469,6 +590,94 @@ function normalizePlatformAllowFrom(value: unknown): Record<string, string> {
|
|
|
469
590
|
return Object.fromEntries(entries);
|
|
470
591
|
}
|
|
471
592
|
|
|
593
|
+
function hasPlatformAllowDeleteMarker(value: unknown): boolean {
|
|
594
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
return Object.entries(value as Record<string, unknown>).some(
|
|
598
|
+
([platform, allowFrom]) =>
|
|
599
|
+
platform.trim().length > 0 && (typeof allowFrom !== 'string' || allowFrom.trim().length === 0)
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function normalizePlatformAllowUpdate(value: unknown): Record<string, string> | undefined {
|
|
604
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
const normalized = normalizePlatformAllowFrom(value);
|
|
608
|
+
if (Object.keys(normalized).length > 0) {
|
|
609
|
+
if (normalized.lark !== undefined) delete normalized.feishu;
|
|
610
|
+
return normalized;
|
|
611
|
+
}
|
|
612
|
+
return Object.keys(value).length === 0 || hasPlatformAllowDeleteMarker(value) ? {} : undefined;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function readStringOption(record: Record<string, unknown>, keys: readonly string[]): string {
|
|
616
|
+
for (const key of keys) {
|
|
617
|
+
const value = record[key];
|
|
618
|
+
if (typeof value === 'string' && value.trim().length > 0) return value.trim();
|
|
619
|
+
}
|
|
620
|
+
return '';
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function persistPlatformRoutingMetadataForProject(
|
|
624
|
+
projectName: string,
|
|
625
|
+
platformType: string,
|
|
626
|
+
options: Record<string, unknown>
|
|
627
|
+
): Promise<void> {
|
|
628
|
+
const project = projectName.trim();
|
|
629
|
+
const platform = platformType.trim();
|
|
630
|
+
if (!project || !platform) return;
|
|
631
|
+
|
|
632
|
+
const allowFrom = readStringOption(options, [
|
|
633
|
+
'allow_from',
|
|
634
|
+
'owner_open_id',
|
|
635
|
+
'owner_user_id',
|
|
636
|
+
'owner_union_id',
|
|
637
|
+
'user_id',
|
|
638
|
+
'open_id',
|
|
639
|
+
]);
|
|
640
|
+
const explicitAllowChat = readStringOption(options, ['allow_chat', 'chat_id', 'open_chat_id']);
|
|
641
|
+
const allowChat = explicitAllowChat || (allowFrom ? '*' : '');
|
|
642
|
+
if (!allowFrom && !allowChat) return;
|
|
643
|
+
|
|
644
|
+
let teamSlug: string;
|
|
645
|
+
try {
|
|
646
|
+
const manifest = await svc.readTeamManifestByProject(project);
|
|
647
|
+
teamSlug = manifest.slug || project;
|
|
648
|
+
} catch {
|
|
649
|
+
teamSlug = project === SYSTEM_MANAGER_BIND_PROJECT ? SYSTEM_MANAGER_TEAM_NAME : project;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let existingFrom: Record<string, string> = {};
|
|
653
|
+
let existingChat: Record<string, string> = {};
|
|
654
|
+
try {
|
|
655
|
+
const manifest = await svc.readTeamManifest(teamSlug);
|
|
656
|
+
existingFrom = normalizePlatformAllowFrom(manifest.platformAllowFrom);
|
|
657
|
+
existingChat = normalizePlatformAllowFrom(manifest.platformAllowChat);
|
|
658
|
+
} catch {
|
|
659
|
+
// Team metadata may not exist for a cc-connect-only project yet.
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const patch: Record<string, unknown> = {};
|
|
663
|
+
if (allowFrom) patch.platformAllowFrom = { ...existingFrom, [platform]: allowFrom };
|
|
664
|
+
if (allowChat) patch.platformAllowChat = { ...existingChat, [platform]: allowChat };
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
await svc.updateTeam(teamSlug, patch);
|
|
668
|
+
} catch (err) {
|
|
669
|
+
app.log.warn(
|
|
670
|
+
{ err, project, teamSlug, platform },
|
|
671
|
+
'failed to persist platform routing metadata'
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function isCcProjectNotFoundError(err: unknown): boolean {
|
|
677
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
678
|
+
return /project not found:/i.test(message);
|
|
679
|
+
}
|
|
680
|
+
|
|
472
681
|
// ===========================================================================
|
|
473
682
|
// SSE 客户端管理器 — 广播 bridge 事件到所有连接的前端客户端
|
|
474
683
|
// ===========================================================================
|
|
@@ -490,11 +699,148 @@ function broadcastSse(eventName: string, data: unknown): void {
|
|
|
490
699
|
// 启动 bridge 并把事件广播到 SSE 客户端
|
|
491
700
|
bridge.start();
|
|
492
701
|
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Direct-CLI execution layer.
|
|
704
|
+
// In-app Loop consoles (admin + team lead) and team-member DMs spawn the local
|
|
705
|
+
// `claude` CLI directly as a long-lived stream-json subprocess, bypassing
|
|
706
|
+
// cc-connect (which is now reserved for external IM). cc-connect's project/
|
|
707
|
+
// work_dir/platform layer was the root cause of "❌ 错误: 启动 Agent 会话失败".
|
|
708
|
+
// Manager events relay to SSE for token-level streaming; the `result` event
|
|
709
|
+
// persists the final reply into the team inbox (same appendMessage path as the
|
|
710
|
+
// bridge reply handler), so the existing renderer refresh Just Works.
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
const directCliManager = new DirectCliSessionManager();
|
|
713
|
+
|
|
714
|
+
/** Routes a sessionKey → the team inbox + reply sender/recipient it belongs to. */
|
|
715
|
+
interface DirectCliRoute {
|
|
716
|
+
teamName: string;
|
|
717
|
+
/** `from` value persisted on the assistant reply (team name for lead, member name for DM). */
|
|
718
|
+
from: string;
|
|
719
|
+
to: string;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const directCliRoutes = new Map<string, DirectCliRoute>();
|
|
723
|
+
|
|
724
|
+
// Per-team tool-approval settings (auto-allow categories). Synced from the renderer on
|
|
725
|
+
// startup via /api/teams/:name/tool-approval/settings. Defaults deny everything so the user
|
|
726
|
+
// is prompted — matching Claude Code's native cmd permission flow.
|
|
727
|
+
const toolApprovalSettingsByName = new Map<string, ToolApprovalSettings>();
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Maps a permission requestId → the DirectCli session it came from (lead or member DM), plus
|
|
731
|
+
* the toolName/toolInput needed to build the AskUserQuestion `updatedInput` at respond time.
|
|
732
|
+
*/
|
|
733
|
+
interface PendingPermissionApproval {
|
|
734
|
+
sessionKey: string;
|
|
735
|
+
toolName?: string;
|
|
736
|
+
toolInput?: Record<string, unknown>;
|
|
737
|
+
}
|
|
738
|
+
const permissionSessionByRequestId = new Map<string, PendingPermissionApproval>();
|
|
739
|
+
|
|
740
|
+
function readToolApprovalSettings(teamName: string): ToolApprovalSettings {
|
|
741
|
+
return toolApprovalSettingsByName.get(teamName) ?? DEFAULT_TOOL_APPROVAL_SETTINGS;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Auto-allow rules (autoAllowAll / file edits / safe-but-not-dangerous bash) live in the
|
|
745
|
+
// shared, unit-tested `toolApprovalRules` util — copied verbatim from the multi-agent
|
|
746
|
+
// reference impl so the rule set (incl. DANGEROUS_PATTERNS that override safe prefixes,
|
|
747
|
+
// e.g. `git rm`) stays byte-identical. Only `can_use_tool` is a real gate; other control
|
|
748
|
+
// subtypes must be auto-allowed or the stream deadlocks on stdin.
|
|
749
|
+
|
|
750
|
+
directCliManager.on('event', (event: DirectCliEvent) => {
|
|
751
|
+
const route = directCliRoutes.get(event.sessionKey);
|
|
752
|
+
if (!route) return;
|
|
753
|
+
const { teamName } = route;
|
|
754
|
+
|
|
755
|
+
if (event.kind === 'complete') {
|
|
756
|
+
void (async () => {
|
|
757
|
+
if (event.text) {
|
|
758
|
+
await svc
|
|
759
|
+
.appendMessage(teamName, {
|
|
760
|
+
// Carry the streaming messageId as the canonical id so the renderer's
|
|
761
|
+
// optimistic in-progress reply (same messageId) is pruned, not duplicated.
|
|
762
|
+
id: event.messageId,
|
|
763
|
+
from: route.from,
|
|
764
|
+
to: route.to,
|
|
765
|
+
role: 'agent',
|
|
766
|
+
content: event.text,
|
|
767
|
+
meta: { sessionKey: event.sessionKey, source: 'direct-cli' },
|
|
768
|
+
})
|
|
769
|
+
.catch((err) =>
|
|
770
|
+
app.log.warn({ err, sessionKey: event.sessionKey }, 'direct-cli append failed')
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
774
|
+
})();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (event.kind === 'error') {
|
|
779
|
+
app.log.warn({ error: event.error, sessionKey: event.sessionKey }, 'direct-cli session error');
|
|
780
|
+
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (event.kind === 'permission-request') {
|
|
785
|
+
void (async () => {
|
|
786
|
+
const settings = readToolApprovalSettings(teamName);
|
|
787
|
+
// Non-`can_use_tool` subtypes (hook_callback, etc.) auto-allow to prevent deadlock;
|
|
788
|
+
// `can_use_tool` goes through the shared shouldAutoAllow rules.
|
|
789
|
+
const autoAllow =
|
|
790
|
+
event.subtype !== 'can_use_tool' ||
|
|
791
|
+
shouldAutoAllow(settings, event.toolName ?? 'Unknown', event.toolInput ?? {}).autoAllow;
|
|
792
|
+
if (autoAllow) {
|
|
793
|
+
try {
|
|
794
|
+
directCliManager.respondPermission(event.sessionKey, event.requestId, true);
|
|
795
|
+
} catch (err) {
|
|
796
|
+
app.log.warn(
|
|
797
|
+
{ err, sessionKey: event.sessionKey },
|
|
798
|
+
'direct-cli auto-allow respond failed'
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
// Surface to the renderer's CC-style approval sheet (Allow / Deny / Allow all). The
|
|
804
|
+
// user's choice comes back via /api/teams/:name/tool-approval/respond, which writes
|
|
805
|
+
// the control_response to stdin and unblocks the turn.
|
|
806
|
+
permissionSessionByRequestId.set(event.requestId, {
|
|
807
|
+
sessionKey: event.sessionKey,
|
|
808
|
+
toolName: event.toolName,
|
|
809
|
+
toolInput: event.toolInput,
|
|
810
|
+
});
|
|
811
|
+
broadcastSse('tool-approval-event', {
|
|
812
|
+
requestId: event.requestId,
|
|
813
|
+
runId: event.runId,
|
|
814
|
+
teamName,
|
|
815
|
+
source: 'lead',
|
|
816
|
+
toolName: event.toolName ?? 'Unknown',
|
|
817
|
+
toolInput: event.toolInput ?? {},
|
|
818
|
+
receivedAt: new Date().toISOString(),
|
|
819
|
+
} satisfies ToolApprovalRequest);
|
|
820
|
+
})();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// init / delta / thinking / tool → live streaming payload for the renderer.
|
|
825
|
+
broadcastSse('team-change', {
|
|
826
|
+
type: 'direct-cli-stream',
|
|
827
|
+
teamName,
|
|
828
|
+
sessionKey: event.sessionKey,
|
|
829
|
+
messageId: 'messageId' in event ? event.messageId : undefined,
|
|
830
|
+
kind: event.kind,
|
|
831
|
+
text: 'text' in event ? event.text : undefined,
|
|
832
|
+
toolName: 'toolName' in event ? event.toolName : undefined,
|
|
833
|
+
toolInput: 'toolInput' in event ? event.toolInput : undefined,
|
|
834
|
+
from: route.from,
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
493
838
|
bridge.on('reply', (msg) => {
|
|
494
839
|
const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
|
|
495
|
-
const teamName = resolveTeamFromSessionKey(sessionKey) ?? sessionKey;
|
|
496
840
|
|
|
497
841
|
void (async () => {
|
|
842
|
+
const teamName = await resolveTeamFromBridgeMessageWithRetry(msg);
|
|
843
|
+
if (!teamName) return;
|
|
498
844
|
// 先落盘再广播,否则前端可能在 appendFile 完成前刷新到旧 feed。
|
|
499
845
|
await svc.appendMessage(teamName, {
|
|
500
846
|
from: teamName,
|
|
@@ -505,19 +851,20 @@ bridge.on('reply', (msg) => {
|
|
|
505
851
|
});
|
|
506
852
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
507
853
|
})().catch((err) => {
|
|
508
|
-
app.log.warn({ err,
|
|
854
|
+
app.log.warn({ err, sessionKey }, 'bridge reply persistence failed');
|
|
509
855
|
});
|
|
510
856
|
});
|
|
511
857
|
|
|
512
858
|
bridge.on('reply_stream', (msg) => {
|
|
513
859
|
const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
|
|
514
|
-
const teamName = resolveTeamFromSessionKey(sessionKey) ?? sessionKey;
|
|
515
860
|
const done = (msg as { done?: boolean }).done ?? false;
|
|
516
861
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
862
|
+
void (async () => {
|
|
863
|
+
const teamName = await resolveTeamFromBridgeMessageWithRetry(msg);
|
|
864
|
+
if (!teamName) return;
|
|
865
|
+
if (done) {
|
|
866
|
+
// 流式结束,存储完整回复
|
|
867
|
+
const fullText = (msg as { full_text?: string }).full_text ?? '';
|
|
521
868
|
if (fullText) {
|
|
522
869
|
await svc.appendMessage(teamName, {
|
|
523
870
|
from: teamName,
|
|
@@ -528,43 +875,148 @@ bridge.on('reply_stream', (msg) => {
|
|
|
528
875
|
});
|
|
529
876
|
}
|
|
530
877
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
});
|
|
534
|
-
} else {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
535
880
|
broadcastSse('team-change', { type: 'lead-message', teamName });
|
|
536
|
-
}
|
|
881
|
+
})().catch((err) => {
|
|
882
|
+
app.log.warn({ err, sessionKey }, 'bridge stream reply persistence failed');
|
|
883
|
+
});
|
|
537
884
|
});
|
|
538
885
|
|
|
539
886
|
bridge.on('message', (msg) => {
|
|
540
887
|
const type = (msg as { type?: string }).type ?? '';
|
|
541
888
|
const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
|
|
542
889
|
if (!sessionKey) return; // 无 session_key 的控制帧(pong 等)不广播
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
890
|
+
|
|
891
|
+
void (async () => {
|
|
892
|
+
const teamName = await resolveTeamFromBridgeMessageWithRetry(msg);
|
|
893
|
+
if (!teamName) return;
|
|
894
|
+
// typing_start/stop → lead-message;其他 → inbox
|
|
895
|
+
const eventType = type === 'typing_start' || type === 'typing_stop' ? 'lead-message' : 'inbox';
|
|
896
|
+
broadcastSse('team-change', { type: eventType, teamName });
|
|
897
|
+
})().catch((err) => {
|
|
898
|
+
app.log.warn({ err, sessionKey, type }, 'bridge message routing failed');
|
|
899
|
+
});
|
|
548
900
|
});
|
|
549
901
|
|
|
902
|
+
const BRIDGE_SESSION_TEAM_CACHE_TTL_MS = 60_000;
|
|
903
|
+
const EXTERNAL_PLATFORM_ROUTE_RETRY_COUNT = 6;
|
|
904
|
+
const EXTERNAL_PLATFORM_ROUTE_RETRY_DELAY_MS = 1_000;
|
|
905
|
+
const bridgeSessionTeamCache = new Map<string, { teamName: string; expiresAt: number }>();
|
|
906
|
+
|
|
550
907
|
/**
|
|
551
|
-
* 从 session_key 解析
|
|
908
|
+
* 从 bridge message/session_key 解析 Hermit team slug。
|
|
909
|
+
*
|
|
910
|
+
* cc-connect 的外部平台 session_key 通常是 `feishu:{chat}:{user}`,不能当作
|
|
911
|
+
* Hermit teamName 使用;否则消息会落到 `~/.hermit/teams/feishu:*` 这类错误目录。
|
|
912
|
+
*/
|
|
913
|
+
async function resolveTeamFromBridgeMessage(msg: unknown): Promise<string | null> {
|
|
914
|
+
const sessionKey = (msg as { session_key?: string }).session_key ?? '';
|
|
915
|
+
if (!sessionKey) return null;
|
|
916
|
+
|
|
917
|
+
const explicitProject = getBridgeMessageProject(msg);
|
|
918
|
+
if (explicitProject) {
|
|
919
|
+
const teamName = await resolveTeamSlugFromCcProject(explicitProject);
|
|
920
|
+
if (teamName) {
|
|
921
|
+
cacheBridgeSessionTeam(sessionKey, teamName);
|
|
922
|
+
return teamName;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const parsedTeamName = parseHermitTeamFromSessionKey(sessionKey);
|
|
927
|
+
if (parsedTeamName) return resolveTeamSlugFromTeamName(parsedTeamName);
|
|
928
|
+
|
|
929
|
+
const cached = bridgeSessionTeamCache.get(sessionKey);
|
|
930
|
+
if (cached && cached.expiresAt > Date.now()) return cached.teamName;
|
|
931
|
+
|
|
932
|
+
if (isExternalPlatformSessionKey(sessionKey)) {
|
|
933
|
+
const teamName = await resolveTeamSlugFromCcSessions(sessionKey);
|
|
934
|
+
if (teamName) {
|
|
935
|
+
cacheBridgeSessionTeam(sessionKey, teamName);
|
|
936
|
+
return teamName;
|
|
937
|
+
}
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return resolveTeamSlugFromTeamName(sessionKey);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function resolveTeamFromBridgeMessageWithRetry(msg: unknown): Promise<string | null> {
|
|
945
|
+
const sessionKey = (msg as { session_key?: string }).session_key ?? '';
|
|
946
|
+
if (!isExternalPlatformSessionKey(sessionKey)) return resolveTeamFromBridgeMessage(msg);
|
|
947
|
+
|
|
948
|
+
for (let attempt = 0; attempt <= EXTERNAL_PLATFORM_ROUTE_RETRY_COUNT; attempt++) {
|
|
949
|
+
const teamName = await resolveTeamFromBridgeMessage(msg);
|
|
950
|
+
if (teamName) return teamName;
|
|
951
|
+
if (attempt < EXTERNAL_PLATFORM_ROUTE_RETRY_COUNT) {
|
|
952
|
+
await new Promise((resolve) => setTimeout(resolve, EXTERNAL_PLATFORM_ROUTE_RETRY_DELAY_MS));
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
app.log.warn(
|
|
957
|
+
{ sessionKey },
|
|
958
|
+
'external platform bridge message could not be mapped to a Hermit team slug'
|
|
959
|
+
);
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function getBridgeMessageProject(msg: unknown): string {
|
|
964
|
+
const raw = msg as { project?: unknown; project_name?: unknown };
|
|
965
|
+
const value = typeof raw.project === 'string' ? raw.project : raw.project_name;
|
|
966
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function cacheBridgeSessionTeam(sessionKey: string, teamName: string): void {
|
|
970
|
+
bridgeSessionTeamCache.set(sessionKey, {
|
|
971
|
+
teamName,
|
|
972
|
+
expiresAt: Date.now() + BRIDGE_SESSION_TEAM_CACHE_TTL_MS,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function resolveTeamSlugFromCcProject(projectName: string): Promise<string | null> {
|
|
977
|
+
try {
|
|
978
|
+
const manifest = await svc.readTeamManifestByProject(projectName);
|
|
979
|
+
return manifest.slug || projectName;
|
|
980
|
+
} catch {
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function resolveTeamSlugFromTeamName(teamName: string): Promise<string | null> {
|
|
986
|
+
try {
|
|
987
|
+
const manifest = await svc.readTeamManifest(teamName);
|
|
988
|
+
return manifest.slug || teamName;
|
|
989
|
+
} catch {
|
|
990
|
+
return teamName;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function resolveTeamSlugFromCcSessions(sessionKey: string): Promise<string | null> {
|
|
995
|
+
const projects = await cc.listProjects().catch(() => []);
|
|
996
|
+
for (const project of projects) {
|
|
997
|
+
const sessions = await cc.listSessions(project.name).catch(() => []);
|
|
998
|
+
if (!sessions.some((session) => session.session_key === sessionKey)) continue;
|
|
999
|
+
return resolveTeamSlugFromCcProject(project.name);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const manifests = await svc.listTeams().catch(() => []);
|
|
1003
|
+
return resolveExternalPlatformSessionTeamSlug(sessionKey, manifests);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* 解析 Hermit 自己生成的 session_key。
|
|
552
1008
|
* 约定格式:
|
|
553
1009
|
* hermit:{teamName}:session (老格式)
|
|
554
1010
|
* hermit:{teamName}:lead (新格式)
|
|
555
1011
|
* bridge:hermit-{team}:{member}
|
|
556
|
-
* {teamName} (直接就是 teamName)
|
|
557
1012
|
*/
|
|
558
|
-
function
|
|
1013
|
+
function parseHermitTeamFromSessionKey(sessionKey: string): string | null {
|
|
559
1014
|
if (!sessionKey) return null;
|
|
560
|
-
// hermit:{teamName}:xxx
|
|
561
1015
|
const hermitMatch = sessionKey.match(/^hermit:([^:]+):/);
|
|
562
1016
|
if (hermitMatch) return hermitMatch[1];
|
|
563
|
-
// bridge:hermit-{team}:{member}
|
|
564
1017
|
const bridgeMatch = sessionKey.match(/^bridge:hermit-([^:]+):/);
|
|
565
1018
|
if (bridgeMatch) return bridgeMatch[1];
|
|
566
|
-
|
|
567
|
-
return sessionKey;
|
|
1019
|
+
return null;
|
|
568
1020
|
}
|
|
569
1021
|
|
|
570
1022
|
const app = Fastify({
|
|
@@ -590,9 +1042,22 @@ const allowedCorsOrigins = configuredCorsOrigins?.length
|
|
|
590
1042
|
];
|
|
591
1043
|
const allowedOriginSet = new Set(allowedCorsOrigins);
|
|
592
1044
|
|
|
1045
|
+
function isLoopbackBrowserOrigin(origin: string): boolean {
|
|
1046
|
+
try {
|
|
1047
|
+
const parsed = new URL(origin);
|
|
1048
|
+
return (
|
|
1049
|
+
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
|
1050
|
+
['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)
|
|
1051
|
+
);
|
|
1052
|
+
} catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
593
1057
|
function isTrustedBrowserOrigin(origin: string | undefined): boolean {
|
|
594
1058
|
if (!origin) return true;
|
|
595
|
-
|
|
1059
|
+
if (allowedOriginSet.has(origin)) return true;
|
|
1060
|
+
return isLoopbackBrowserOrigin(origin);
|
|
596
1061
|
}
|
|
597
1062
|
|
|
598
1063
|
function assertTrustedBrowserOrigin(request: import('fastify').FastifyRequest): void {
|
|
@@ -660,17 +1125,15 @@ async function proxyToCcConnect(
|
|
|
660
1125
|
);
|
|
661
1126
|
return reply.code(upstream.status).send({
|
|
662
1127
|
ok: false,
|
|
663
|
-
error:
|
|
1128
|
+
error:
|
|
1129
|
+
`cc-connect 端点 ${subPath} 返回了非 JSON 响应 (HTTP ${upstream.status})。` +
|
|
664
1130
|
'请检查 cc-connect 是否正在运行且支持该端点。',
|
|
665
1131
|
});
|
|
666
1132
|
}
|
|
667
1133
|
|
|
668
1134
|
return reply
|
|
669
1135
|
.code(upstream.status)
|
|
670
|
-
.header(
|
|
671
|
-
'Content-Type',
|
|
672
|
-
contentType || 'application/json; charset=utf-8'
|
|
673
|
-
)
|
|
1136
|
+
.header('Content-Type', contentType || 'application/json; charset=utf-8')
|
|
674
1137
|
.send(body);
|
|
675
1138
|
}
|
|
676
1139
|
|
|
@@ -990,18 +1453,8 @@ app.patch<{ Body: Record<string, unknown> }>('/api/cc-settings', async (request)
|
|
|
990
1453
|
// restart / reload cc-connect
|
|
991
1454
|
app.post('/api/cc-restart', async () => {
|
|
992
1455
|
try {
|
|
993
|
-
await
|
|
994
|
-
|
|
995
|
-
for (let i = 0; i < 30; i++) {
|
|
996
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
997
|
-
try {
|
|
998
|
-
await cc.listProjects();
|
|
999
|
-
return { ok: true };
|
|
1000
|
-
} catch {
|
|
1001
|
-
/* not back yet */
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
return reply500(new Error('cc-connect did not come back within 30s'));
|
|
1456
|
+
await restartCcConnectAndReconnectBridge();
|
|
1457
|
+
return { ok: true };
|
|
1005
1458
|
} catch (err) {
|
|
1006
1459
|
return reply500(err);
|
|
1007
1460
|
}
|
|
@@ -1020,10 +1473,14 @@ app.post('/api/cc-reload', async () => {
|
|
|
1020
1473
|
// Teams — cc-connect projects 即团队,本地 ~/.hermit/teams/ 仅存 tasks + 额外元数据
|
|
1021
1474
|
// ===========================================================================
|
|
1022
1475
|
|
|
1023
|
-
// POST /api/system-manager/ensure →
|
|
1476
|
+
// POST /api/system-manager/ensure → 确保项目级 Helm Loop存在
|
|
1024
1477
|
app.post('/api/system-manager/ensure', async (_request, reply) => {
|
|
1025
1478
|
try {
|
|
1026
|
-
|
|
1479
|
+
const summary = await ensureSystemManager();
|
|
1480
|
+
// Fire-and-forget the one-shot ops-guide bootstrap. Idempotent (skips once the
|
|
1481
|
+
// marker is set) and retries on fetch failure each time the console opens.
|
|
1482
|
+
void ensureAdminLoopInitialized();
|
|
1483
|
+
return summary;
|
|
1027
1484
|
} catch (err) {
|
|
1028
1485
|
return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1029
1486
|
}
|
|
@@ -1039,7 +1496,13 @@ app.get('/api/system-manager/status', async (_request, reply) => {
|
|
|
1039
1496
|
|
|
1040
1497
|
app.get('/api/system-manager/config', async (_request, reply) => {
|
|
1041
1498
|
try {
|
|
1042
|
-
|
|
1499
|
+
const config = await systemManagerConfig.getConfig();
|
|
1500
|
+
// Seed builtin commands into workspace's .claude/commands/ if missing.
|
|
1501
|
+
// Await so the control console can list quick workflow buttons immediately.
|
|
1502
|
+
if (config.selectedWorkDir) {
|
|
1503
|
+
await seedBuiltinWorkflows(config.selectedWorkDir);
|
|
1504
|
+
}
|
|
1505
|
+
return config;
|
|
1043
1506
|
} catch (err) {
|
|
1044
1507
|
return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1045
1508
|
}
|
|
@@ -1050,7 +1513,7 @@ app.put<{ Body: { selectedWorkDir?: string; workflowFolder?: string | null } }>(
|
|
|
1050
1513
|
async (request, reply) => {
|
|
1051
1514
|
try {
|
|
1052
1515
|
const config = await systemManagerConfig.updateConfig(request.body ?? {});
|
|
1053
|
-
await
|
|
1516
|
+
await seedBuiltinWorkflows(config.selectedWorkDir);
|
|
1054
1517
|
return config;
|
|
1055
1518
|
} catch (err) {
|
|
1056
1519
|
return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) });
|
|
@@ -1070,126 +1533,157 @@ app.post<{ Body: { folder?: string } }>(
|
|
|
1070
1533
|
const result = await workflowPromptService.list(folder);
|
|
1071
1534
|
await systemManagerConfig.updateConfig({ workflowFolder: result.folder });
|
|
1072
1535
|
return result;
|
|
1073
|
-
} catch {
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1538
|
+
if (message.startsWith('Forbidden origin:')) {
|
|
1539
|
+
return reply.code(403).send({ error: message });
|
|
1540
|
+
}
|
|
1074
1541
|
return { folder: '', prompts: [], warnings: [] };
|
|
1075
1542
|
}
|
|
1076
1543
|
}
|
|
1077
1544
|
);
|
|
1078
1545
|
|
|
1079
|
-
app.post<{ Body: { id?: string } }>(
|
|
1546
|
+
app.post<{ Body: { folder?: string; id?: string } }>(
|
|
1080
1547
|
'/api/system-manager/workflows/read',
|
|
1081
1548
|
async (request, reply) => {
|
|
1082
1549
|
try {
|
|
1083
1550
|
assertTrustedBrowserOrigin(request);
|
|
1084
1551
|
const config = await systemManagerConfig.getConfig();
|
|
1085
|
-
|
|
1086
|
-
|
|
1552
|
+
const folder =
|
|
1553
|
+
typeof request.body?.folder === 'string' && request.body.folder.trim().length > 0
|
|
1554
|
+
? request.body.folder
|
|
1555
|
+
: config.workflowFolder;
|
|
1556
|
+
if (!folder) return reply.code(400).send({ error: 'workflowFolder is not configured' });
|
|
1087
1557
|
const id = typeof request.body?.id === 'string' ? request.body.id : '';
|
|
1088
|
-
return await workflowPromptService.read(
|
|
1558
|
+
return await workflowPromptService.read(folder, id);
|
|
1089
1559
|
} catch (err) {
|
|
1090
1560
|
return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1091
1561
|
}
|
|
1092
1562
|
}
|
|
1093
1563
|
);
|
|
1094
1564
|
|
|
1095
|
-
|
|
1096
|
-
'/
|
|
1097
|
-
|
|
1098
|
-
try {
|
|
1099
|
-
assertTrustedBrowserOrigin(request);
|
|
1100
|
-
const requestedCwd = typeof request.body?.cwd === 'string' ? request.body.cwd.trim() : '';
|
|
1101
|
-
const command = typeof request.body?.command === 'string' ? request.body.command : 'claude';
|
|
1102
|
-
const args = Array.isArray(request.body?.args) ? request.body.args : [];
|
|
1103
|
-
|
|
1104
|
-
// Use requested cwd if provided; otherwise fall back to system manager config
|
|
1105
|
-
let cwd: string;
|
|
1106
|
-
if (requestedCwd) {
|
|
1107
|
-
cwd = requestedCwd;
|
|
1108
|
-
// Update system manager config to track the work dir
|
|
1109
|
-
await systemManagerConfig.updateConfig({ selectedWorkDir: cwd });
|
|
1110
|
-
await syncSystemManagerManifestWorkDir(cwd);
|
|
1111
|
-
} else {
|
|
1112
|
-
const config = await systemManagerConfig.getConfig();
|
|
1113
|
-
cwd = config.selectedWorkDir;
|
|
1114
|
-
}
|
|
1565
|
+
function shellQuote(value: string): string {
|
|
1566
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
1567
|
+
}
|
|
1115
1568
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1569
|
+
function escapeAppleScriptString(value: string): string {
|
|
1570
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function appleScriptStringLiteral(value: string): string {
|
|
1574
|
+
return value
|
|
1575
|
+
.replace(/\r\n/g, '\n')
|
|
1576
|
+
.replace(/\r/g, '\n')
|
|
1577
|
+
.split('\n')
|
|
1578
|
+
.map((line) => `"${escapeAppleScriptString(line)}"`)
|
|
1579
|
+
.join(' & linefeed & ');
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function execFileAsync(file: string, args: string[]): Promise<void> {
|
|
1583
|
+
return new Promise((resolve, reject) => {
|
|
1584
|
+
void import('node:child_process')
|
|
1585
|
+
.then(({ execFile }) => {
|
|
1586
|
+
execFile(file, args, (error) => {
|
|
1587
|
+
if (error) {
|
|
1588
|
+
reject(error);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
resolve();
|
|
1592
|
+
});
|
|
1593
|
+
})
|
|
1594
|
+
.catch(reject);
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function spawnDetached(file: string, args: string[]): Promise<void> {
|
|
1599
|
+
return new Promise((resolve, reject) => {
|
|
1600
|
+
void import('node:child_process')
|
|
1601
|
+
.then(({ spawn }) => {
|
|
1602
|
+
const child = spawn(file, args, { detached: true, stdio: 'ignore' });
|
|
1603
|
+
child.once('error', reject);
|
|
1604
|
+
child.once('spawn', () => {
|
|
1605
|
+
child.unref();
|
|
1606
|
+
resolve();
|
|
1607
|
+
});
|
|
1608
|
+
})
|
|
1609
|
+
.catch(reject);
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function cmdQuote(value: string): string {
|
|
1614
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Launches commands in an external/system terminal only; no embedded terminal mode.
|
|
1618
|
+
async function openCommandInSystemTerminal(
|
|
1619
|
+
shellLine: string,
|
|
1620
|
+
windowsShellLine: string
|
|
1621
|
+
): Promise<void> {
|
|
1622
|
+
if (process.platform === 'darwin') {
|
|
1623
|
+
const script = `tell application "Terminal"\ndo script ${appleScriptStringLiteral(shellLine)}\nactivate\nend tell`;
|
|
1624
|
+
await execFileAsync('osascript', ['-e', script]);
|
|
1625
|
+
return;
|
|
1130
1626
|
}
|
|
1131
|
-
);
|
|
1132
1627
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
try {
|
|
1137
|
-
assertTrustedBrowserOrigin(request);
|
|
1138
|
-
systemManagerPty.write(request.params.ptyId, request.body?.data ?? '');
|
|
1139
|
-
return { ok: true };
|
|
1140
|
-
} catch (err) {
|
|
1141
|
-
return reply.code(404).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1142
|
-
}
|
|
1628
|
+
if (process.platform === 'win32') {
|
|
1629
|
+
await spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', windowsShellLine]);
|
|
1630
|
+
return;
|
|
1143
1631
|
}
|
|
1144
|
-
);
|
|
1145
1632
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1633
|
+
const candidates = [
|
|
1634
|
+
...(process.env.TERMINAL
|
|
1635
|
+
? [{ file: process.env.TERMINAL, args: ['-e', 'sh', '-lc', shellLine] }]
|
|
1636
|
+
: []),
|
|
1637
|
+
{ file: 'x-terminal-emulator', args: ['-e', 'sh', '-lc', shellLine] },
|
|
1638
|
+
{ file: 'gnome-terminal', args: ['--', 'sh', '-lc', shellLine] },
|
|
1639
|
+
{ file: 'konsole', args: ['-e', 'sh', '-lc', shellLine] },
|
|
1640
|
+
{ file: 'xfce4-terminal', args: ['-e', 'sh', '-lc', shellLine] },
|
|
1641
|
+
{ file: 'alacritty', args: ['-e', 'sh', '-lc', shellLine] },
|
|
1642
|
+
{ file: 'kitty', args: ['sh', '-lc', shellLine] },
|
|
1643
|
+
{ file: 'wezterm', args: ['start', '--', 'sh', '-lc', shellLine] },
|
|
1644
|
+
];
|
|
1645
|
+
|
|
1646
|
+
const errors: string[] = [];
|
|
1647
|
+
for (const candidate of candidates) {
|
|
1149
1648
|
try {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
request.params.ptyId,
|
|
1153
|
-
request.body?.cols ?? 120,
|
|
1154
|
-
request.body?.rows ?? 34
|
|
1155
|
-
);
|
|
1156
|
-
return { ok: true };
|
|
1649
|
+
await spawnDetached(candidate.file, candidate.args);
|
|
1650
|
+
return;
|
|
1157
1651
|
} catch (err) {
|
|
1158
|
-
|
|
1652
|
+
errors.push(`${candidate.file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1159
1653
|
}
|
|
1160
1654
|
}
|
|
1161
|
-
);
|
|
1162
|
-
|
|
1163
|
-
app.delete<{ Params: { ptyId: string } }>('/api/terminal/:ptyId', async (request, reply) => {
|
|
1164
|
-
try {
|
|
1165
|
-
assertTrustedBrowserOrigin(request);
|
|
1166
|
-
await systemManagerPty.kill(request.params.ptyId);
|
|
1167
|
-
return { ok: true };
|
|
1168
|
-
} catch (err) {
|
|
1169
|
-
return reply.code(403).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1170
|
-
}
|
|
1171
|
-
});
|
|
1655
|
+
throw new Error(`No system terminal launcher succeeded. ${errors.join('; ')}`);
|
|
1656
|
+
}
|
|
1172
1657
|
|
|
1173
|
-
// POST /api/terminal/open-external — open command in system
|
|
1658
|
+
// POST /api/terminal/open-external — open command in an external/system terminal
|
|
1174
1659
|
app.post<{ Body: { command: string; args?: string[]; cwd?: string } }>(
|
|
1175
1660
|
'/api/terminal/open-external',
|
|
1176
1661
|
async (request, reply) => {
|
|
1177
1662
|
try {
|
|
1663
|
+
assertTrustedBrowserOrigin(request);
|
|
1178
1664
|
const { command, args = [], cwd } = request.body ?? {};
|
|
1179
1665
|
if (!command) return reply.code(400).send({ error: 'command is required' });
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1666
|
+
const normalizedArgs = Array.isArray(args)
|
|
1667
|
+
? args.filter((arg) => typeof arg === 'string')
|
|
1668
|
+
: [];
|
|
1669
|
+
const cmd = [command, ...normalizedArgs].map(shellQuote).join(' ');
|
|
1670
|
+
const shellLine = cwd ? `cd ${shellQuote(cwd)} && ${cmd}` : cmd;
|
|
1671
|
+
const windowsCmd = [command, ...normalizedArgs].map(cmdQuote).join(' ');
|
|
1672
|
+
const windowsShellLine = cwd ? `cd /d ${cmdQuote(cwd)} && ${windowsCmd}` : windowsCmd;
|
|
1673
|
+
await openCommandInSystemTerminal(shellLine, windowsShellLine);
|
|
1186
1674
|
return { ok: true };
|
|
1187
1675
|
} catch (err) {
|
|
1188
|
-
|
|
1676
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1677
|
+
return reply
|
|
1678
|
+
.code(message.startsWith('Forbidden origin:') ? 403 : 500)
|
|
1679
|
+
.send({ error: message });
|
|
1189
1680
|
}
|
|
1190
1681
|
}
|
|
1191
1682
|
);
|
|
1192
1683
|
|
|
1684
|
+
// Worker Society REST 路由(/api/society/*)—— worker 自治社会的 HTTP 接口(workers/needs/social/feed)。
|
|
1685
|
+
registerSocietyRoutes(app, workerSociety);
|
|
1686
|
+
|
|
1193
1687
|
// GET /api/teams → Hermit 本地团队优先,裸 cc-connect project 作为历史兼容显示;过滤飞书/系统项目
|
|
1194
1688
|
app.get('/api/teams', async () => {
|
|
1195
1689
|
try {
|
|
@@ -1215,21 +1709,14 @@ app.get('/api/teams', async () => {
|
|
|
1215
1709
|
.map(async (meta) => {
|
|
1216
1710
|
const bindProject = meta.bindProject || meta.slug;
|
|
1217
1711
|
const project = projectByName.get(bindProject);
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
const detail = await cc.getProject(bindProject);
|
|
1223
|
-
if (typeof detail.work_dir === 'string' && detail.work_dir.trim()) {
|
|
1224
|
-
workDir = detail.work_dir.trim();
|
|
1225
|
-
}
|
|
1226
|
-
} catch {
|
|
1227
|
-
// ignore detail read failure, keep manifest/default path
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1712
|
+
// Keep the list endpoint fast: per-team cc.getProject calls are slow and
|
|
1713
|
+
// block first paint. Runtime liveness is loaded separately via aliveList.
|
|
1714
|
+
const workDir = (meta.workDir || '').trim();
|
|
1715
|
+
const projectPath = (meta.workDir || '').trim();
|
|
1230
1716
|
const harness = toCcAgentType(project?.agent_type || meta.harness);
|
|
1231
1717
|
const color = meta.color || 'blue';
|
|
1232
1718
|
const displayName = meta.displayName || meta.slug;
|
|
1719
|
+
const usageStats = workDir ? getProjectStatsSnapshot(workDir) : null;
|
|
1233
1720
|
|
|
1234
1721
|
return {
|
|
1235
1722
|
teamName: meta.slug,
|
|
@@ -1240,16 +1727,27 @@ app.get('/api/teams', async () => {
|
|
|
1240
1727
|
members: [{ name: displayName, role: 'agent', agentId: harness, color }],
|
|
1241
1728
|
taskCount: 0,
|
|
1242
1729
|
lastActivity: null,
|
|
1243
|
-
isAlive:
|
|
1730
|
+
isAlive: false,
|
|
1244
1731
|
harness,
|
|
1245
1732
|
bindProject,
|
|
1246
1733
|
workDir,
|
|
1247
|
-
projectPath:
|
|
1734
|
+
projectPath: projectPath || undefined,
|
|
1248
1735
|
sessionsCount: project?.sessions_count ?? 0,
|
|
1249
1736
|
heartbeatEnabled: project?.heartbeat_enabled ?? false,
|
|
1250
1737
|
pendingDelete: meta.pendingDelete === true,
|
|
1251
1738
|
restartRequired: meta.restartRequired === true,
|
|
1252
|
-
stats:
|
|
1739
|
+
stats: usageStats
|
|
1740
|
+
? {
|
|
1741
|
+
sessions: usageStats.sessions,
|
|
1742
|
+
messages: usageStats.messages,
|
|
1743
|
+
tokens: usageStats.totalTokens,
|
|
1744
|
+
tokensIn: usageStats.tokensIn,
|
|
1745
|
+
tokensOut: usageStats.tokensOut,
|
|
1746
|
+
cacheRead: usageStats.cacheRead,
|
|
1747
|
+
cacheCreation: usageStats.cacheCreation,
|
|
1748
|
+
durationMs: usageStats.durationMs,
|
|
1749
|
+
}
|
|
1750
|
+
: undefined,
|
|
1253
1751
|
};
|
|
1254
1752
|
})
|
|
1255
1753
|
);
|
|
@@ -1264,23 +1762,30 @@ app.get('/api/teams', async () => {
|
|
|
1264
1762
|
app.post('/api/teams/create', async (request, reply) => {
|
|
1265
1763
|
try {
|
|
1266
1764
|
const body = (request.body ?? {}) as Record<string, unknown>;
|
|
1267
|
-
const
|
|
1268
|
-
const displayName = String(body.displayName ?? body.teamName ?? '').trim()
|
|
1765
|
+
const bindProject = String(body.bindProject ?? '').trim();
|
|
1766
|
+
const displayName = String(body.displayName ?? body.teamName ?? '').trim();
|
|
1269
1767
|
const harness = String(body.harness ?? 'claudecode');
|
|
1270
1768
|
let workDir = String(body.workDir ?? body.cwd ?? '');
|
|
1271
1769
|
|
|
1272
|
-
if (!
|
|
1770
|
+
if (!bindProject) return reply.code(400).send({ error: 'bindProject required' });
|
|
1771
|
+
if (!displayName) return reply.code(400).send({ error: 'displayName required' });
|
|
1273
1772
|
if (!workDir) return reply.code(400).send({ error: 'workDir required' });
|
|
1274
1773
|
|
|
1275
|
-
//
|
|
1774
|
+
// Validate bindProject is ASCII-safe (for URL routing and cc-connect project name)
|
|
1775
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(bindProject)) {
|
|
1776
|
+
return reply.code(400).send({
|
|
1777
|
+
error: '项目标识只能包含小写英文字母、数字、连字符和下划线,且必须以字母或数字开头',
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Check for duplicate bindProject (unique identifier, replaces displayName duplicate check)
|
|
1276
1782
|
const existingTeams = await svc.listTeams().catch(() => []);
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
(t) => t.displayName?.toLowerCase() === normalizedName
|
|
1783
|
+
const duplicateProject = existingTeams.find(
|
|
1784
|
+
(t) => t.bindProject?.toLowerCase() === bindProject.toLowerCase()
|
|
1280
1785
|
);
|
|
1281
|
-
if (
|
|
1786
|
+
if (duplicateProject) {
|
|
1282
1787
|
return reply.code(409).send({
|
|
1283
|
-
error:
|
|
1788
|
+
error: `项目标识"${bindProject}"已被"${duplicateProject.displayName}"使用,请换一个。`,
|
|
1284
1789
|
});
|
|
1285
1790
|
}
|
|
1286
1791
|
|
|
@@ -1293,7 +1798,7 @@ app.post('/api/teams/create', async (request, reply) => {
|
|
|
1293
1798
|
// 本地创建只落 Hermit 团队目录;飞书/微信等外部平台在团队内按需绑定。
|
|
1294
1799
|
await svc.createTeam({
|
|
1295
1800
|
displayName,
|
|
1296
|
-
bindProject
|
|
1801
|
+
bindProject,
|
|
1297
1802
|
harness,
|
|
1298
1803
|
workDir,
|
|
1299
1804
|
color: typeof body.color === 'string' ? body.color : undefined,
|
|
@@ -1301,7 +1806,7 @@ app.post('/api/teams/create', async (request, reply) => {
|
|
|
1301
1806
|
createCcProject: false,
|
|
1302
1807
|
});
|
|
1303
1808
|
|
|
1304
|
-
return { runId: `local:${
|
|
1809
|
+
return { runId: `local:${bindProject}:${Date.now()}` };
|
|
1305
1810
|
} catch (err) {
|
|
1306
1811
|
return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1307
1812
|
}
|
|
@@ -1437,7 +1942,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
|
|
|
1437
1942
|
disabledCommands: resolvedDisabledCommands,
|
|
1438
1943
|
platformAllowFrom: resolvedPlatformAllowFrom,
|
|
1439
1944
|
platformAllowChat: resolvedPlatformAllowChat,
|
|
1440
|
-
projectPath: p.work_dir
|
|
1945
|
+
projectPath: workDir || p.work_dir,
|
|
1441
1946
|
members: [{ name: displayName, role: 'lead' }],
|
|
1442
1947
|
},
|
|
1443
1948
|
tasks: teamTasks,
|
|
@@ -1495,6 +2000,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
|
|
|
1495
2000
|
managedSources,
|
|
1496
2001
|
disabledCommands,
|
|
1497
2002
|
platformAllowFrom,
|
|
2003
|
+
platformAllowChat,
|
|
1498
2004
|
projectPath: workDir,
|
|
1499
2005
|
members: [{ name: displayName, role: 'lead' }],
|
|
1500
2006
|
},
|
|
@@ -1531,6 +2037,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
|
|
|
1531
2037
|
reply_footer: replyFooter,
|
|
1532
2038
|
inject_sender: injectSender,
|
|
1533
2039
|
platform_allow_from: platformAllowFrom,
|
|
2040
|
+
platform_allow_chat: platformAllowChat,
|
|
1534
2041
|
},
|
|
1535
2042
|
activeSessions: [],
|
|
1536
2043
|
};
|
|
@@ -1556,21 +2063,39 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
|
|
|
1556
2063
|
async (request, reply) => {
|
|
1557
2064
|
const teamName = request.params.name;
|
|
1558
2065
|
if (isReservedSystemTeamName(teamName)) {
|
|
1559
|
-
return reply.code(403).send({ error: '
|
|
2066
|
+
return reply.code(403).send({ error: 'Helm Loop 不可删除' });
|
|
1560
2067
|
}
|
|
1561
2068
|
try {
|
|
1562
2069
|
let restartRequired = false;
|
|
2070
|
+
let ccProjectName = teamName;
|
|
2071
|
+
let localTeamName = teamName;
|
|
1563
2072
|
try {
|
|
1564
|
-
const
|
|
2073
|
+
const manifest = await svc.readTeamManifestByProject(teamName);
|
|
2074
|
+
ccProjectName = manifest.bindProject || teamName;
|
|
2075
|
+
localTeamName = manifest.slug || teamName;
|
|
2076
|
+
} catch {
|
|
2077
|
+
// Team may only exist in cc-connect or local metadata may already be gone.
|
|
2078
|
+
}
|
|
2079
|
+
if (isReservedSystemTeamName(ccProjectName) || isReservedSystemTeamName(localTeamName)) {
|
|
2080
|
+
return reply.code(403).send({ error: 'Helm Loop 不可删除' });
|
|
2081
|
+
}
|
|
2082
|
+
try {
|
|
2083
|
+
const result = await cc.deleteProject(ccProjectName);
|
|
1565
2084
|
restartRequired = result.restart_required === true;
|
|
1566
2085
|
} catch (err) {
|
|
1567
|
-
request.log.warn(
|
|
2086
|
+
request.log.warn(
|
|
2087
|
+
{ err, teamName, ccProjectName },
|
|
2088
|
+
'delete cc-connect project failed or project missing'
|
|
2089
|
+
);
|
|
1568
2090
|
}
|
|
1569
2091
|
|
|
1570
2092
|
try {
|
|
1571
|
-
await svc.deleteTeam(
|
|
2093
|
+
await svc.deleteTeam(localTeamName, { deleteFiles: request.query.deleteFiles === 'true' });
|
|
1572
2094
|
} catch (err) {
|
|
1573
|
-
request.log.warn(
|
|
2095
|
+
request.log.warn(
|
|
2096
|
+
{ err, teamName, localTeamName },
|
|
2097
|
+
'delete local team metadata failed or already missing'
|
|
2098
|
+
);
|
|
1574
2099
|
}
|
|
1575
2100
|
|
|
1576
2101
|
return { ok: true, restartRequired };
|
|
@@ -1583,7 +2108,7 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
|
|
|
1583
2108
|
// ===========================================================================
|
|
1584
2109
|
// Tasks — 存储在 ~/.hermit/teams/:name/tasks/board.json
|
|
1585
2110
|
// 双向映射:TeamTask(pending/in_progress/completed) ↔ Task(todo/doing/done)
|
|
1586
|
-
//
|
|
2111
|
+
// 任务创建/指派只更新看板;只有显式点击开始才投递给 runtime/目标团队。
|
|
1587
2112
|
// ===========================================================================
|
|
1588
2113
|
|
|
1589
2114
|
/** TeamTask status → internal Task status */
|
|
@@ -1593,6 +2118,13 @@ function toTaskStatus(s: string): 'todo' | 'doing' | 'done' {
|
|
|
1593
2118
|
return 'todo';
|
|
1594
2119
|
}
|
|
1595
2120
|
|
|
2121
|
+
function isManualInProgressExitBlocked(
|
|
2122
|
+
currentStatus: string | undefined,
|
|
2123
|
+
nextStatus: 'todo' | 'doing' | 'done' | undefined
|
|
2124
|
+
): boolean {
|
|
2125
|
+
return currentStatus === 'doing' && nextStatus !== undefined && nextStatus !== 'doing';
|
|
2126
|
+
}
|
|
2127
|
+
|
|
1596
2128
|
/** internal Task → TeamTask shape (for UI consumption) */
|
|
1597
2129
|
function toTeamTask(task: {
|
|
1598
2130
|
id: string;
|
|
@@ -1635,36 +2167,6 @@ function activeTasks<T extends { result?: string | null }>(tasks: T[]): T[] {
|
|
|
1635
2167
|
return tasks.filter((task) => !isSoftDeletedTask(task));
|
|
1636
2168
|
}
|
|
1637
2169
|
|
|
1638
|
-
function mapCcSessionDetail(detail: {
|
|
1639
|
-
id: string;
|
|
1640
|
-
name: string;
|
|
1641
|
-
session_key: string;
|
|
1642
|
-
agent_session_id?: string;
|
|
1643
|
-
agent_type: string;
|
|
1644
|
-
active: boolean;
|
|
1645
|
-
live: boolean;
|
|
1646
|
-
history_count: number;
|
|
1647
|
-
created_at: string;
|
|
1648
|
-
updated_at: string;
|
|
1649
|
-
platform: string;
|
|
1650
|
-
history: { role: 'user' | 'assistant'; content: string; timestamp: string }[];
|
|
1651
|
-
}) {
|
|
1652
|
-
return {
|
|
1653
|
-
id: detail.id,
|
|
1654
|
-
name: detail.name,
|
|
1655
|
-
sessionKey: detail.session_key,
|
|
1656
|
-
agentSessionId: detail.agent_session_id,
|
|
1657
|
-
agentType: detail.agent_type,
|
|
1658
|
-
active: detail.active,
|
|
1659
|
-
live: detail.live,
|
|
1660
|
-
historyCount: detail.history_count,
|
|
1661
|
-
createdAt: detail.created_at,
|
|
1662
|
-
updatedAt: detail.updated_at,
|
|
1663
|
-
platform: detail.platform,
|
|
1664
|
-
history: detail.history ?? [],
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
2170
|
app.get<{ Params: { name: string } }>('/api/teams/:name/tasks', async (request) => {
|
|
1669
2171
|
try {
|
|
1670
2172
|
const tasks = activeTasks(await svc.readTasks(request.params.name));
|
|
@@ -1687,33 +2189,34 @@ app.post<{ Params: { name: string }; Body: Record<string, unknown> }>(
|
|
|
1687
2189
|
assignee: (body.owner ?? body.assignee) as string | null | undefined,
|
|
1688
2190
|
status: body.status ? toTaskStatus(body.status as string) : 'todo',
|
|
1689
2191
|
});
|
|
1690
|
-
if (task.assignee) {
|
|
1691
|
-
svc
|
|
1692
|
-
.dispatchTask(request.params.name, task)
|
|
1693
|
-
.catch((err) => request.log.warn({ err }, 'dispatchTask failed'));
|
|
1694
|
-
}
|
|
1695
2192
|
return toTeamTask(task);
|
|
1696
2193
|
}
|
|
1697
2194
|
);
|
|
1698
2195
|
|
|
1699
2196
|
app.patch<{ Params: { name: string; id: string }; Body: Record<string, unknown> }>(
|
|
1700
2197
|
'/api/teams/:name/tasks/:id',
|
|
1701
|
-
async (request) => {
|
|
2198
|
+
async (request, reply) => {
|
|
1702
2199
|
const body = request.body ?? {};
|
|
1703
2200
|
const patch: Record<string, unknown> = {};
|
|
2201
|
+
const nextStatus = body.status !== undefined ? toTaskStatus(body.status as string) : undefined;
|
|
1704
2202
|
if (body.subject !== undefined) patch.title = body.subject;
|
|
1705
2203
|
if (body.title !== undefined) patch.title = body.title;
|
|
1706
2204
|
if (body.description !== undefined) patch.description = body.description;
|
|
1707
|
-
if (
|
|
2205
|
+
if (nextStatus !== undefined) patch.status = nextStatus;
|
|
1708
2206
|
if (body.owner !== undefined) patch.assignee = body.owner;
|
|
1709
2207
|
if (body.assignee !== undefined) patch.assignee = body.assignee;
|
|
1710
2208
|
if (body.result !== undefined) patch.result = body.result;
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2209
|
+
|
|
2210
|
+
const tasks = await svc.readTasks(request.params.name);
|
|
2211
|
+
const existingTask = tasks.find((task) => task.id === request.params.id);
|
|
2212
|
+
if (isManualInProgressExitBlocked(existingTask?.status, nextStatus)) {
|
|
2213
|
+
return reply.code(409).send({
|
|
2214
|
+
ok: false,
|
|
2215
|
+
error: 'Agent 正在处理中,不能手动完成或取消。请等待 agent 调用 complete_task。',
|
|
2216
|
+
});
|
|
1716
2217
|
}
|
|
2218
|
+
|
|
2219
|
+
const task = await svc.patchTask(request.params.name, request.params.id, patch);
|
|
1717
2220
|
return toTeamTask(task);
|
|
1718
2221
|
}
|
|
1719
2222
|
);
|
|
@@ -1722,6 +2225,14 @@ app.delete<{ Params: { name: string; id: string } }>(
|
|
|
1722
2225
|
'/api/teams/:name/tasks/:id',
|
|
1723
2226
|
async (request, reply) => {
|
|
1724
2227
|
try {
|
|
2228
|
+
const tasks = await svc.readTasks(request.params.name);
|
|
2229
|
+
const existingTask = tasks.find((task) => task.id === request.params.id);
|
|
2230
|
+
if (existingTask?.status === 'doing') {
|
|
2231
|
+
return reply.code(409).send({
|
|
2232
|
+
ok: false,
|
|
2233
|
+
error: 'Agent 正在处理中,不能手动删除任务。',
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
1725
2236
|
await svc.patchTask(request.params.name, request.params.id, {
|
|
1726
2237
|
status: 'done',
|
|
1727
2238
|
result: '__deleted__',
|
|
@@ -1854,7 +2365,279 @@ app.get('/api/harnesses', async () => {
|
|
|
1854
2365
|
}));
|
|
1855
2366
|
} catch {
|
|
1856
2367
|
// cc-connect 不可达时返回完整枚举列表
|
|
1857
|
-
return CC_AGENT_TYPES.map((type) => ({
|
|
2368
|
+
return CC_AGENT_TYPES.map((type) => ({
|
|
2369
|
+
type,
|
|
2370
|
+
inUse: false,
|
|
2371
|
+
}));
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
function mapCcSessionListItem(session: CcSessionListItem, projectId: string): CcSession {
|
|
2376
|
+
return {
|
|
2377
|
+
id: session.agent_session_id || session.id,
|
|
2378
|
+
title: session.name || session.session_key,
|
|
2379
|
+
projectId,
|
|
2380
|
+
sessionKey: session.session_key,
|
|
2381
|
+
platform: session.platform,
|
|
2382
|
+
userName: session.user_name ?? null,
|
|
2383
|
+
chatName: session.chat_name ?? null,
|
|
2384
|
+
active: session.active,
|
|
2385
|
+
live: session.live,
|
|
2386
|
+
historyCount: session.history_count,
|
|
2387
|
+
createdAt: session.created_at,
|
|
2388
|
+
updatedAt: session.updated_at,
|
|
2389
|
+
lastMessage: session.last_message
|
|
2390
|
+
? {
|
|
2391
|
+
role: session.last_message.role,
|
|
2392
|
+
content: session.last_message.content,
|
|
2393
|
+
timestamp: session.last_message.timestamp,
|
|
2394
|
+
}
|
|
2395
|
+
: null,
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
app.get<{ Params: { name: string } }>('/api/teams/:name/loop-assets', async (request, reply) => {
|
|
2400
|
+
try {
|
|
2401
|
+
const name = request.params.name;
|
|
2402
|
+
const manifest = await svc.readTeamManifest(name);
|
|
2403
|
+
let bindProject = manifest.bindProject || name;
|
|
2404
|
+
let workDir = manifest.workDir || '';
|
|
2405
|
+
let platforms: { type: string; connected?: boolean }[] = [];
|
|
2406
|
+
|
|
2407
|
+
try {
|
|
2408
|
+
bindProject = await resolveRouteCcProjectName(name);
|
|
2409
|
+
const project = await cc.getProject(bindProject).catch(() => null);
|
|
2410
|
+
if (!workDir && project?.work_dir) workDir = project.work_dir;
|
|
2411
|
+
platforms = Array.isArray(project?.platforms)
|
|
2412
|
+
? project.platforms.map((platform) => ({
|
|
2413
|
+
type: platform.type,
|
|
2414
|
+
connected: platform.connected,
|
|
2415
|
+
}))
|
|
2416
|
+
: [];
|
|
2417
|
+
} catch {
|
|
2418
|
+
/* Local manifest data is enough for a best-effort scan. */
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
const [tasks, messages] = await Promise.all([
|
|
2422
|
+
svc.readTasks(name).catch(() => []),
|
|
2423
|
+
svc.readMessages(name).catch(() => []),
|
|
2424
|
+
]);
|
|
2425
|
+
|
|
2426
|
+
return await loopAssetsScanner.scanTeam({
|
|
2427
|
+
teamName: name,
|
|
2428
|
+
displayName: manifest.displayName,
|
|
2429
|
+
bindProject,
|
|
2430
|
+
workDir,
|
|
2431
|
+
teamRoot: manifest.rootPath,
|
|
2432
|
+
memberCount: 1,
|
|
2433
|
+
taskCount: activeTasks(tasks).length,
|
|
2434
|
+
messageCount: messages.length,
|
|
2435
|
+
platforms,
|
|
2436
|
+
});
|
|
2437
|
+
} catch (err) {
|
|
2438
|
+
return reply.code(404).send({ error: err instanceof Error ? err.message : String(err) });
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
async function ensureLoopSessionProjectReady(teamName: string): Promise<{
|
|
2443
|
+
bindProject: string;
|
|
2444
|
+
projectExists: boolean;
|
|
2445
|
+
isOnline: boolean;
|
|
2446
|
+
}> {
|
|
2447
|
+
if (teamName === SYSTEM_MANAGER_TEAM_NAME) {
|
|
2448
|
+
await ensureSystemManager();
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
let manifest: TeamManifest | null = null;
|
|
2452
|
+
try {
|
|
2453
|
+
manifest = await svc.readTeamManifestByProject(teamName);
|
|
2454
|
+
} catch {
|
|
2455
|
+
// Route name may already be a cc-connect project name.
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
const bindProject = manifest?.bindProject?.trim() || teamName;
|
|
2459
|
+
let projectExists = false;
|
|
2460
|
+
let isOnline = false;
|
|
2461
|
+
let workDir = manifest?.workDir?.trim() || '';
|
|
2462
|
+
const harness = manifest?.harness || 'claudecode';
|
|
2463
|
+
const platformType = manifest?.platform || 'bridge';
|
|
2464
|
+
const platformOptions = manifest?.platformOptions ?? {};
|
|
2465
|
+
|
|
2466
|
+
let projectWorkDir = '';
|
|
2467
|
+
try {
|
|
2468
|
+
const project = await cc.getProject(bindProject);
|
|
2469
|
+
projectExists = true;
|
|
2470
|
+
isOnline =
|
|
2471
|
+
Array.isArray(project.platforms) && project.platforms.some((platform) => platform.connected);
|
|
2472
|
+
if (typeof project.work_dir === 'string') projectWorkDir = project.work_dir.trim();
|
|
2473
|
+
// Only inherit the project's work_dir when the manifest has none AND it isn't the
|
|
2474
|
+
// cc-connect default template placeholder — adopting the placeholder would keep the
|
|
2475
|
+
// agent pointed at a non-existent directory and break every session.
|
|
2476
|
+
if (!workDir && !isPlaceholderWorkDir(projectWorkDir)) {
|
|
2477
|
+
workDir = projectWorkDir;
|
|
2478
|
+
}
|
|
2479
|
+
} catch {
|
|
2480
|
+
// Project can be missing after cc-connect reset; create it below when possible.
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// Reconcile work_dir: cc-connect spawns the agent with chdir(work_dir), so a stale or
|
|
2484
|
+
// placeholder work_dir makes every session fail with "启动 Agent 会话失败" — the session
|
|
2485
|
+
// record is created (so the user sees the success message) but the agent never starts.
|
|
2486
|
+
// This runs whether or not the project is "online": the Helm Loop's bind project is
|
|
2487
|
+
// `my-project`, which is online via bridge yet still carries the template placeholder
|
|
2488
|
+
// work_dir, so the isOnline branch below would skip it. The PATCH updates the live agent
|
|
2489
|
+
// immediately and persists to config.toml (no restart required).
|
|
2490
|
+
if (projectExists && workDir && needsWorkDirReconcile(projectWorkDir, workDir)) {
|
|
2491
|
+
try {
|
|
2492
|
+
await cc.updateProject(bindProject, { work_dir: workDir });
|
|
2493
|
+
projectWorkDir = workDir;
|
|
2494
|
+
} catch (err) {
|
|
2495
|
+
app.log.warn({ err, bindProject, workDir }, 'cc-connect work_dir reconcile failed');
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
if (!isOnline) {
|
|
2500
|
+
if (!projectExists) {
|
|
2501
|
+
if (!workDir) {
|
|
2502
|
+
throw new Error('团队缺少项目路径,无法启动 Loop runtime');
|
|
2503
|
+
}
|
|
2504
|
+
await cc.createProject(
|
|
2505
|
+
bindProject,
|
|
2506
|
+
harness,
|
|
2507
|
+
workDir,
|
|
2508
|
+
platformType,
|
|
2509
|
+
platformOptions as Record<string, string>
|
|
2510
|
+
);
|
|
2511
|
+
projectExists = true;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
await restartCcConnectAndReconnectBridge();
|
|
2515
|
+
try {
|
|
2516
|
+
const project = await cc.getProject(bindProject);
|
|
2517
|
+
isOnline =
|
|
2518
|
+
Array.isArray(project.platforms) &&
|
|
2519
|
+
project.platforms.some((platform) => platform.connected);
|
|
2520
|
+
} catch {
|
|
2521
|
+
isOnline = false;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
return { bindProject, projectExists, isOnline };
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
/**
|
|
2529
|
+
* Resolve the work_dir for a direct-CLI session WITHOUT cc-connect side effects (no
|
|
2530
|
+
* project create / restart). Prefers the team manifest's workDir; falls back to the
|
|
2531
|
+
* cc-connect project work_dir only when it is a real path (never the template
|
|
2532
|
+
* placeholder). The system-manager workDir is synced into its manifest from the runtime
|
|
2533
|
+
* config, so this reads the same source for admin and team loops.
|
|
2534
|
+
*/
|
|
2535
|
+
async function resolveDirectCliWorkDir(teamName: string): Promise<string> {
|
|
2536
|
+
if (teamName === SYSTEM_MANAGER_TEAM_NAME) {
|
|
2537
|
+
await ensureSystemManager().catch(() => undefined);
|
|
2538
|
+
}
|
|
2539
|
+
let manifest: TeamManifest | null = null;
|
|
2540
|
+
try {
|
|
2541
|
+
manifest = await svc.readTeamManifestByProject(teamName);
|
|
2542
|
+
} catch {
|
|
2543
|
+
// Route name may already be a cc-connect project name.
|
|
2544
|
+
}
|
|
2545
|
+
const manifestWorkDir = manifest?.workDir?.trim() || '';
|
|
2546
|
+
if (manifestWorkDir) return manifestWorkDir;
|
|
2547
|
+
try {
|
|
2548
|
+
const bindProject = manifest?.bindProject?.trim() || teamName;
|
|
2549
|
+
const project = await cc.getProject(bindProject);
|
|
2550
|
+
if (typeof project.work_dir === 'string') {
|
|
2551
|
+
const dir = project.work_dir.trim();
|
|
2552
|
+
if (dir && !isPlaceholderWorkDir(dir)) return dir;
|
|
2553
|
+
}
|
|
2554
|
+
} catch {
|
|
2555
|
+
// Project may not exist — that's fine for direct-CLI.
|
|
2556
|
+
}
|
|
2557
|
+
return '';
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/**
|
|
2561
|
+
* Register a direct-CLI session route and dispatch a user turn to it. The subprocess
|
|
2562
|
+
* spawns lazily (resuming a persisted claude session when possible) and this resolves
|
|
2563
|
+
* once the turn is on stdin; the streamed reply arrives later via the manager event
|
|
2564
|
+
* listener above.
|
|
2565
|
+
*/
|
|
2566
|
+
async function dispatchDirectCliMessage(params: {
|
|
2567
|
+
teamName: string;
|
|
2568
|
+
sessionKey: string;
|
|
2569
|
+
workDir: string;
|
|
2570
|
+
from: string;
|
|
2571
|
+
to: string;
|
|
2572
|
+
text: string;
|
|
2573
|
+
messageId: string;
|
|
2574
|
+
}): Promise<void> {
|
|
2575
|
+
directCliRoutes.set(params.sessionKey, {
|
|
2576
|
+
teamName: params.teamName,
|
|
2577
|
+
from: params.from,
|
|
2578
|
+
to: params.to,
|
|
2579
|
+
});
|
|
2580
|
+
await directCliManager.send(params.sessionKey, {
|
|
2581
|
+
text: params.text,
|
|
2582
|
+
messageId: params.messageId,
|
|
2583
|
+
workDir: params.workDir,
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
app.post<{
|
|
2588
|
+
Params: { name: string };
|
|
2589
|
+
Body: { sessionName?: unknown; message?: unknown; reuse?: unknown };
|
|
2590
|
+
}>('/api/teams/:name/loop-session', async (request, reply) => {
|
|
2591
|
+
try {
|
|
2592
|
+
const teamName = request.params.name;
|
|
2593
|
+
const message = typeof request.body?.message === 'string' ? request.body.message.trim() : '';
|
|
2594
|
+
const reuse = request.body?.reuse === true;
|
|
2595
|
+
const requestedSessionName =
|
|
2596
|
+
typeof request.body?.sessionName === 'string' ? request.body.sessionName.trim() : '';
|
|
2597
|
+
const sessionName =
|
|
2598
|
+
requestedSessionName || `Loop ${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
2599
|
+
|
|
2600
|
+
const workDir = await resolveDirectCliWorkDir(teamName);
|
|
2601
|
+
if (!workDir) {
|
|
2602
|
+
return reply.code(400).send({ error: '团队缺少项目路径,无法启动 Loop runtime' });
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// One long-lived lead subprocess per team, resumed across sends (--resume keeps the
|
|
2606
|
+
// claude conversation continuous, like an interactive terminal session).
|
|
2607
|
+
const sessionKey = `${teamName}:lead`;
|
|
2608
|
+
// "Reused" means the claude conversation continues (--resume), which is true
|
|
2609
|
+
// whenever a session id is known — in-memory OR persisted in the store. The
|
|
2610
|
+
// in-memory-only `has()` would wrongly report false right after a Hermit
|
|
2611
|
+
// restart even though the subprocess resumes the same conversation.
|
|
2612
|
+
const reused = reuse && directCliManager.getSessionId(sessionKey) != null;
|
|
2613
|
+
|
|
2614
|
+
let messageSent = false;
|
|
2615
|
+
if (message) {
|
|
2616
|
+
const messageId = buildDirectReplyMessageId(sessionKey);
|
|
2617
|
+
await dispatchDirectCliMessage({
|
|
2618
|
+
teamName,
|
|
2619
|
+
sessionKey,
|
|
2620
|
+
workDir,
|
|
2621
|
+
from: teamName,
|
|
2622
|
+
to: 'user',
|
|
2623
|
+
text: message,
|
|
2624
|
+
messageId,
|
|
2625
|
+
});
|
|
2626
|
+
messageSent = true;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
return {
|
|
2630
|
+
session: {
|
|
2631
|
+
id: directCliManager.getSessionId(sessionKey) ?? sessionKey,
|
|
2632
|
+
name: sessionName,
|
|
2633
|
+
session_key: sessionKey,
|
|
2634
|
+
title: sessionName,
|
|
2635
|
+
},
|
|
2636
|
+
reused,
|
|
2637
|
+
messageSent,
|
|
2638
|
+
};
|
|
2639
|
+
} catch (err) {
|
|
2640
|
+
return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1858
2641
|
}
|
|
1859
2642
|
});
|
|
1860
2643
|
|
|
@@ -1872,12 +2655,12 @@ app.post<{ Params: { name: string }; Body: Partial<TeamLaunchRequest> }>(
|
|
|
1872
2655
|
const body = request.body ?? {};
|
|
1873
2656
|
let manifest: TeamManifest | null = null;
|
|
1874
2657
|
try {
|
|
1875
|
-
manifest = await svc.
|
|
2658
|
+
manifest = await svc.readTeamManifestByProject(name);
|
|
1876
2659
|
} catch {
|
|
1877
2660
|
// Team may only exist in cc-connect.
|
|
1878
2661
|
}
|
|
1879
2662
|
const bindProject = manifest?.bindProject ?? name;
|
|
1880
|
-
|
|
2663
|
+
let workDir = body.cwd ?? manifest?.workDir ?? '';
|
|
1881
2664
|
const harness = manifest?.harness ?? 'claudecode';
|
|
1882
2665
|
const platformType = manifest?.platform ?? 'bridge';
|
|
1883
2666
|
const platformOptions = manifest?.platformOptions ?? {};
|
|
@@ -1905,14 +2688,21 @@ app.post<{ Params: { name: string }; Body: Partial<TeamLaunchRequest> }>(
|
|
|
1905
2688
|
platformOptions as Record<string, string>
|
|
1906
2689
|
);
|
|
1907
2690
|
projectExists = true;
|
|
1908
|
-
} catch {
|
|
2691
|
+
} catch {
|
|
2692
|
+
/* CC Connect project creation is best-effort */
|
|
2693
|
+
}
|
|
1909
2694
|
}
|
|
1910
2695
|
// Restart cc-connect to (re-)activate platform connections.
|
|
1911
2696
|
// Covers: newly created project, existing project with disconnected platform,
|
|
1912
2697
|
// Feishu/Lark IM that lost connection after cc-connect restart, etc.
|
|
1913
2698
|
try {
|
|
1914
|
-
await
|
|
1915
|
-
} catch {
|
|
2699
|
+
await restartCcConnectAndReconnectBridge();
|
|
2700
|
+
} catch (err) {
|
|
2701
|
+
request.log.warn(
|
|
2702
|
+
{ err, bindProject },
|
|
2703
|
+
'cc-connect restart/bridge reconnect failed during team launch'
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
1916
2706
|
}
|
|
1917
2707
|
|
|
1918
2708
|
return {
|
|
@@ -1932,7 +2722,9 @@ app.post<{ Params: { name: string } }>('/api/teams/:name/stop', async (request)
|
|
|
1932
2722
|
// Stop = delete project from cc-connect (best-effort, no restart)
|
|
1933
2723
|
try {
|
|
1934
2724
|
await cc.deleteProject(bindProject);
|
|
1935
|
-
} catch {
|
|
2725
|
+
} catch {
|
|
2726
|
+
/* project may not exist in cc-connect */
|
|
2727
|
+
}
|
|
1936
2728
|
// Keep local team metadata intact by not deleting it
|
|
1937
2729
|
// The team will show as offline (isAlive: false) on next data fetch
|
|
1938
2730
|
return { ok: true };
|
|
@@ -1982,16 +2774,27 @@ app.post('/api/setup/feishu/poll', async (request, reply) => {
|
|
|
1982
2774
|
|
|
1983
2775
|
app.post('/api/setup/feishu/save', async (request, reply) => {
|
|
1984
2776
|
try {
|
|
1985
|
-
const
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2777
|
+
const requestBody = (request.body ?? {}) as Record<string, unknown>;
|
|
2778
|
+
const response = await fetch(`${runtimeConfig.ccBaseUrl}/api/v1/setup/feishu/save`, {
|
|
2779
|
+
method: 'POST',
|
|
2780
|
+
headers: {
|
|
2781
|
+
'Content-Type': 'application/json',
|
|
2782
|
+
...(runtimeConfig.ccToken ? { Authorization: `Bearer ${runtimeConfig.ccToken}` } : {}),
|
|
2783
|
+
},
|
|
2784
|
+
body: JSON.stringify(requestBody),
|
|
2785
|
+
});
|
|
2786
|
+
const result = (await response.json()) as { data?: unknown; error?: unknown };
|
|
2787
|
+
if (!response.ok) {
|
|
2788
|
+
return reply.code(response.status).send(result);
|
|
2789
|
+
}
|
|
2790
|
+
const resultData = result && typeof result.data === 'object' ? result.data : result;
|
|
2791
|
+
if (resultData && typeof resultData === 'object' && !('error' in resultData)) {
|
|
2792
|
+
await persistPlatformRoutingMetadataForProject(
|
|
2793
|
+
typeof requestBody.project === 'string' ? requestBody.project : '',
|
|
2794
|
+
typeof requestBody.platform_type === 'string' ? requestBody.platform_type : 'feishu',
|
|
2795
|
+
requestBody
|
|
2796
|
+
);
|
|
2797
|
+
}
|
|
1995
2798
|
return result;
|
|
1996
2799
|
} catch (err) {
|
|
1997
2800
|
return reply500(err);
|
|
@@ -2037,16 +2840,27 @@ app.post('/api/setup/weixin/poll', async (request, reply) => {
|
|
|
2037
2840
|
|
|
2038
2841
|
app.post('/api/setup/weixin/save', async (request, reply) => {
|
|
2039
2842
|
try {
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2843
|
+
const requestBody = (request.body ?? {}) as Record<string, unknown>;
|
|
2844
|
+
const response = await fetch(`${runtimeConfig.ccBaseUrl}/api/v1/setup/weixin/save`, {
|
|
2845
|
+
method: 'POST',
|
|
2846
|
+
headers: {
|
|
2847
|
+
'Content-Type': 'application/json',
|
|
2848
|
+
...(runtimeConfig.ccToken ? { Authorization: `Bearer ${runtimeConfig.ccToken}` } : {}),
|
|
2849
|
+
},
|
|
2850
|
+
body: JSON.stringify(requestBody),
|
|
2851
|
+
});
|
|
2852
|
+
const result = (await response.json()) as { data?: unknown; error?: unknown };
|
|
2853
|
+
if (!response.ok) {
|
|
2854
|
+
return reply.code(response.status).send(result);
|
|
2855
|
+
}
|
|
2856
|
+
const resultData = result && typeof result.data === 'object' ? result.data : result;
|
|
2857
|
+
if (resultData && typeof resultData === 'object' && !('error' in resultData)) {
|
|
2858
|
+
await persistPlatformRoutingMetadataForProject(
|
|
2859
|
+
typeof requestBody.project === 'string' ? requestBody.project : '',
|
|
2860
|
+
'weixin',
|
|
2861
|
+
requestBody
|
|
2862
|
+
);
|
|
2863
|
+
}
|
|
2050
2864
|
return result;
|
|
2051
2865
|
} catch (err) {
|
|
2052
2866
|
return reply500(err);
|
|
@@ -2059,14 +2873,31 @@ app.post<{
|
|
|
2059
2873
|
Body: { type: string; options?: Record<string, unknown>; work_dir?: string; agent_type?: string };
|
|
2060
2874
|
}>('/api/projects/:name/add-platform', async (request, reply) => {
|
|
2061
2875
|
try {
|
|
2876
|
+
const existingProject = await cc.getProject(request.params.name).catch(() => null);
|
|
2062
2877
|
const result = await cc.createProject(
|
|
2063
2878
|
request.params.name,
|
|
2064
|
-
request.body.agent_type ?? 'claudecode',
|
|
2065
|
-
request.body.work_dir ?? '',
|
|
2879
|
+
request.body.agent_type ?? existingProject?.agent_type ?? 'claudecode',
|
|
2880
|
+
request.body.work_dir ?? existingProject?.work_dir ?? '',
|
|
2066
2881
|
request.body.type,
|
|
2067
2882
|
(request.body.options ?? {}) as Record<string, string>
|
|
2068
2883
|
);
|
|
2069
|
-
|
|
2884
|
+
|
|
2885
|
+
await persistPlatformRoutingMetadataForProject(
|
|
2886
|
+
request.params.name,
|
|
2887
|
+
request.body.type,
|
|
2888
|
+
request.body.options ?? {}
|
|
2889
|
+
);
|
|
2890
|
+
|
|
2891
|
+
if (result.restart_required) {
|
|
2892
|
+
// Adding Feishu/Lark/other platform engines only writes cc-connect config; a restart is
|
|
2893
|
+
// required before cc-connect listens to the new long-connection and Hermit must reconnect
|
|
2894
|
+
// its Bridge adapter after that restart. Do it here so callers cannot accidentally leave
|
|
2895
|
+
// cc-connect showing “connected” while Hermit is not listening.
|
|
2896
|
+
await restartCcConnectAndReconnectBridge();
|
|
2897
|
+
return { ok: true, data: { ...result, restart_required: false, restart_handled: true } };
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
return { ok: true, data: { ...result, restart_handled: false } };
|
|
2070
2901
|
} catch (err) {
|
|
2071
2902
|
return reply500(err);
|
|
2072
2903
|
}
|
|
@@ -2247,6 +3078,8 @@ const MCP_TOOLS = [
|
|
|
2247
3078
|
required: ['team_slug', 'dispatch_id', 'feedback'],
|
|
2248
3079
|
},
|
|
2249
3080
|
},
|
|
3081
|
+
// Worker Society —— 去中心化自治社会的 MCP 工具(society_* 命名空间)。
|
|
3082
|
+
...SOCIETY_MCP_TOOLS,
|
|
2250
3083
|
];
|
|
2251
3084
|
|
|
2252
3085
|
/** 执行 MCP tool,返回 content array */
|
|
@@ -2256,12 +3089,28 @@ async function executeMcpTool(
|
|
|
2256
3089
|
): Promise<{ type: string; text: string }[]> {
|
|
2257
3090
|
const text = async (result: unknown) => [{ type: 'text', text: JSON.stringify(result, null, 2) }];
|
|
2258
3091
|
|
|
3092
|
+
// Worker Society 工具(society_*):命中即返回,未命中回退到既有派单/任务工具。
|
|
3093
|
+
const societyResult = await executeSocietyMcpTool(toolName, args, workerSociety);
|
|
3094
|
+
if (societyResult) return societyResult;
|
|
3095
|
+
|
|
2259
3096
|
if (toolName === 'list_tasks') {
|
|
2260
3097
|
const tasks = await svc.readTasks(args.team_slug);
|
|
2261
3098
|
return text(tasks);
|
|
2262
3099
|
}
|
|
2263
3100
|
|
|
2264
3101
|
if (toolName === 'claim_task') {
|
|
3102
|
+
const tasks = await svc.readTasks(args.team_slug);
|
|
3103
|
+
const existingTask = tasks.find((task) => task.id === args.task_id);
|
|
3104
|
+
if (
|
|
3105
|
+
existingTask?.dispatchMeta &&
|
|
3106
|
+
existingTask.status === 'todo' &&
|
|
3107
|
+
['received', 'pending_accept'].includes(existingTask.dispatchMeta.status)
|
|
3108
|
+
) {
|
|
3109
|
+
return text({
|
|
3110
|
+
ok: false,
|
|
3111
|
+
error: 'Cross-team tasks must be started from the target team TODO board by clicking 启动.',
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
2265
3114
|
const task = await svc.patchTask(args.team_slug, args.task_id, { status: 'doing' });
|
|
2266
3115
|
return text(task);
|
|
2267
3116
|
}
|
|
@@ -2397,8 +3246,10 @@ app.post<{
|
|
|
2397
3246
|
// Hermit 主仓 UI 首屏强依赖的几个 stub(占位实现)
|
|
2398
3247
|
// ===========================================================================
|
|
2399
3248
|
|
|
2400
|
-
// hermit getAppVersion
|
|
2401
|
-
app.get('/api/version', async () =>
|
|
3249
|
+
// hermit getAppVersion 期望返回 JSON 字符串;Fastify 直接 send(string) 会按 text/plain 返回。
|
|
3250
|
+
app.get('/api/version', async (_request, reply) =>
|
|
3251
|
+
reply.type('application/json').send(JSON.stringify(pkg.version))
|
|
3252
|
+
);
|
|
2402
3253
|
|
|
2403
3254
|
// GET /api/update/check — 检查是否有新版本
|
|
2404
3255
|
const updateService = new UpdateService();
|
|
@@ -2540,9 +3391,10 @@ function readAppConfig() {
|
|
|
2540
3391
|
return mergeConfigDefaults(DEFAULT_APP_CONFIG, raw);
|
|
2541
3392
|
}
|
|
2542
3393
|
} catch (err) {
|
|
2543
|
-
const msg =
|
|
2544
|
-
|
|
2545
|
-
|
|
3394
|
+
const msg =
|
|
3395
|
+
err instanceof SyntaxError
|
|
3396
|
+
? `${HERMIT_APP_CONFIG_FILE} 格式错误: ${err.message}。将使用默认配置并覆盖修复。`
|
|
3397
|
+
: `读取 ${HERMIT_APP_CONFIG_FILE} 失败`;
|
|
2546
3398
|
app.log.warn({ err }, msg);
|
|
2547
3399
|
// Auto-heal: rewrite with valid defaults
|
|
2548
3400
|
try {
|
|
@@ -2677,13 +3529,14 @@ async function sendHarnessMessageViaBridge(params: {
|
|
|
2677
3529
|
await waitForHarnessBridgeConnected();
|
|
2678
3530
|
|
|
2679
3531
|
const sessionKey = params.sessionKey?.trim() || buildFallbackSessionKey(params.teamName);
|
|
3532
|
+
const projectName = await resolveRouteCcProjectName(params.teamName);
|
|
2680
3533
|
bridge.sendUserMessage({
|
|
2681
3534
|
sessionKey,
|
|
2682
3535
|
userId: 'hermit-user',
|
|
2683
3536
|
userName: 'User',
|
|
2684
3537
|
content: params.text,
|
|
2685
3538
|
msgId: params.msgId,
|
|
2686
|
-
project:
|
|
3539
|
+
project: projectName,
|
|
2687
3540
|
});
|
|
2688
3541
|
return sessionKey;
|
|
2689
3542
|
}
|
|
@@ -2758,7 +3611,10 @@ function mapCronJobToSchedule(
|
|
|
2758
3611
|
let nextRunAt: string | undefined;
|
|
2759
3612
|
if (cronJob.enabled && isNonEmptyString(cronJob.cron_expr)) {
|
|
2760
3613
|
try {
|
|
2761
|
-
const job = new Cron(cronJob.cron_expr.trim(), {
|
|
3614
|
+
const job = new Cron(cronJob.cron_expr.trim(), {
|
|
3615
|
+
timezone: DEFAULT_SCHEDULE_TIMEZONE,
|
|
3616
|
+
paused: true,
|
|
3617
|
+
});
|
|
2762
3618
|
const next = job.nextRun();
|
|
2763
3619
|
if (next) {
|
|
2764
3620
|
nextRunAt = (next instanceof Date ? next : new Date(next)).toISOString();
|
|
@@ -2883,7 +3739,6 @@ app.post<{ Body: Record<string, unknown> }>('/api/schedules', async (request, re
|
|
|
2883
3739
|
.code(400)
|
|
2884
3740
|
.send({ error: 'teamName、cronExpression、launchConfig.prompt 不能为空' });
|
|
2885
3741
|
}
|
|
2886
|
-
|
|
2887
3742
|
const created = await cc.createCronJob({
|
|
2888
3743
|
project: teamName,
|
|
2889
3744
|
session_key: sessionKey,
|
|
@@ -3221,25 +4076,23 @@ app.post<{ Body: { dirPath?: string } }>('/api/workspace/list', async (request)
|
|
|
3221
4076
|
|
|
3222
4077
|
try {
|
|
3223
4078
|
const entries = readdirSync(target, { withFileTypes: true });
|
|
3224
|
-
const files = entries
|
|
3225
|
-
.
|
|
3226
|
-
.
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
};
|
|
3242
|
-
});
|
|
4079
|
+
const files = entries.slice(0, 500).map((e) => {
|
|
4080
|
+
const fullPath = path.join(target, e.name);
|
|
4081
|
+
const isDirectory = e.isDirectory();
|
|
4082
|
+
let size = 0;
|
|
4083
|
+
try {
|
|
4084
|
+
const stat = statSync(fullPath);
|
|
4085
|
+
size = stat.size;
|
|
4086
|
+
} catch {
|
|
4087
|
+
/* ignore */
|
|
4088
|
+
}
|
|
4089
|
+
return {
|
|
4090
|
+
name: e.name,
|
|
4091
|
+
isDirectory,
|
|
4092
|
+
size,
|
|
4093
|
+
ext: isDirectory ? '' : path.extname(e.name).slice(1).toLowerCase(),
|
|
4094
|
+
};
|
|
4095
|
+
});
|
|
3243
4096
|
return { path: target, files, hasParent: target !== path.dirname(target) };
|
|
3244
4097
|
} catch {
|
|
3245
4098
|
return { path: target, files: [], hasParent: false, error: `无法访问目录: ${target}` };
|
|
@@ -3709,63 +4562,38 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/sessions', async (reques
|
|
|
3709
4562
|
const workDir = team.workDir || team.bindProject || request.params.name;
|
|
3710
4563
|
const localSessions = await localSessionScanner.scanSummaries(workDir, request.params.name);
|
|
3711
4564
|
|
|
3712
|
-
//
|
|
3713
|
-
|
|
4565
|
+
// Merge cc-connect sessions into the response. External platform sessions (Feishu/Lark/etc.)
|
|
4566
|
+
// may not have a local Claude JSONL yet, but users still expect to see them as listening sessions.
|
|
4567
|
+
let ccSessions: CcSessionListItem[] = [];
|
|
3714
4568
|
try {
|
|
3715
4569
|
const bindProject = await resolveRouteCcProjectName(request.params.name);
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
userName: s.user_name ?? null,
|
|
3721
|
-
chatName: s.chat_name ?? null,
|
|
3722
|
-
lastMessage: s.last_message
|
|
3723
|
-
? { role: s.last_message.role, content: s.last_message.content, timestamp: s.last_message.timestamp }
|
|
3724
|
-
: null,
|
|
3725
|
-
});
|
|
3726
|
-
}
|
|
3727
|
-
} catch { /* cc-connect unavailable — local-only data */ }
|
|
4570
|
+
ccSessions = await cc.listSessions(bindProject);
|
|
4571
|
+
} catch {
|
|
4572
|
+
/* cc-connect unavailable — local-only data */
|
|
4573
|
+
}
|
|
3728
4574
|
|
|
3729
|
-
return localSessions.
|
|
3730
|
-
const ccMeta = ccById.get(s.id);
|
|
3731
|
-
return {
|
|
3732
|
-
id: s.id,
|
|
3733
|
-
title: s.title || s.id,
|
|
3734
|
-
projectId: request.params.name,
|
|
3735
|
-
sessionKey: s.id,
|
|
3736
|
-
platform: ccMeta?.platform ?? 'local',
|
|
3737
|
-
userName: ccMeta?.userName ?? null,
|
|
3738
|
-
chatName: ccMeta?.chatName ?? null,
|
|
3739
|
-
active: s.active,
|
|
3740
|
-
live: s.live,
|
|
3741
|
-
historyCount: s.messageCount,
|
|
3742
|
-
createdAt: s.createdAt,
|
|
3743
|
-
updatedAt: s.updatedAt,
|
|
3744
|
-
lastMessage: ccMeta?.lastMessage ?? null,
|
|
3745
|
-
};
|
|
3746
|
-
});
|
|
4575
|
+
return mergeLocalAndCcSessions(localSessions, ccSessions, request.params.name);
|
|
3747
4576
|
} catch {
|
|
3748
4577
|
return [];
|
|
3749
4578
|
}
|
|
3750
4579
|
});
|
|
3751
4580
|
|
|
3752
4581
|
// GET session detail — read local JSONL file for session history with pagination
|
|
3753
|
-
app.get<{
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
);
|
|
4582
|
+
app.get<{
|
|
4583
|
+
Params: { name: string; sessionId: string };
|
|
4584
|
+
Querystring: { history_limit?: string; offset?: string };
|
|
4585
|
+
}>('/api/teams/:name/sessions/:sessionId', async (request, reply) => {
|
|
4586
|
+
const limit = request.query.history_limit ? parseInt(request.query.history_limit, 10) : 500;
|
|
4587
|
+
const offset = request.query.offset ? parseInt(request.query.offset, 10) : 0;
|
|
4588
|
+
const team = await svc.readTeamManifest(request.params.name);
|
|
4589
|
+
const workDir = team.workDir || team.bindProject || request.params.name;
|
|
4590
|
+
const detail = await localSessionScanner.readSessionDetail(workDir, request.params.sessionId, {
|
|
4591
|
+
offset,
|
|
4592
|
+
limit,
|
|
4593
|
+
});
|
|
4594
|
+
if (!detail) return reply.code(404).send({ error: 'Session not found' });
|
|
4595
|
+
return detail;
|
|
4596
|
+
});
|
|
3769
4597
|
|
|
3770
4598
|
// DELETE session — 关闭 cc-connect live session,使其从运行中转为历史会话。
|
|
3771
4599
|
app.delete<{ Params: { name: string; sessionId: string } }>(
|
|
@@ -3826,15 +4654,8 @@ app.post<{ Params: { name: string }; Body: { text?: string; message?: string } }
|
|
|
3826
4654
|
try {
|
|
3827
4655
|
const text = request.body?.text ?? request.body?.message ?? '';
|
|
3828
4656
|
if (text) {
|
|
3829
|
-
let targetProject = request.params.name;
|
|
3830
|
-
try {
|
|
3831
|
-
const manifest = await svc.readTeamManifest(request.params.name);
|
|
3832
|
-
targetProject = manifest.bindProject || request.params.name;
|
|
3833
|
-
} catch {
|
|
3834
|
-
// request.params.name may already be a cc-connect project name.
|
|
3835
|
-
}
|
|
3836
4657
|
await sendHarnessMessageViaBridge({
|
|
3837
|
-
teamName:
|
|
4658
|
+
teamName: request.params.name,
|
|
3838
4659
|
text,
|
|
3839
4660
|
});
|
|
3840
4661
|
}
|
|
@@ -3888,8 +4709,16 @@ app.get('/api/teams/tasks', async () => {
|
|
|
3888
4709
|
// 团队任务子操作 — 全部委托给 svc.patchTask
|
|
3889
4710
|
app.post<{ Params: { name: string; id: string } }>(
|
|
3890
4711
|
'/api/teams/:name/tasks/:id/request-review',
|
|
3891
|
-
async (request) => {
|
|
4712
|
+
async (request, reply) => {
|
|
3892
4713
|
try {
|
|
4714
|
+
const tasks = await svc.readTasks(request.params.name);
|
|
4715
|
+
const existingTask = tasks.find((task) => task.id === request.params.id);
|
|
4716
|
+
if (existingTask?.status === 'doing') {
|
|
4717
|
+
return reply.code(409).send({
|
|
4718
|
+
ok: false,
|
|
4719
|
+
error: 'Agent 正在处理中,不能手动提交审核。请等待 agent 调用 complete_task。',
|
|
4720
|
+
});
|
|
4721
|
+
}
|
|
3893
4722
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'done' });
|
|
3894
4723
|
return { ok: true, data: toTeamTask(task) };
|
|
3895
4724
|
} catch {
|
|
@@ -3906,12 +4735,24 @@ app.patch<{ Params: { name: string; id: string }; Body: Record<string, unknown>
|
|
|
3906
4735
|
);
|
|
3907
4736
|
app.patch<{ Params: { name: string; id: string }; Body: { status?: string } }>(
|
|
3908
4737
|
'/api/teams/:name/tasks/:id/status',
|
|
3909
|
-
async (request) => {
|
|
4738
|
+
async (request, reply) => {
|
|
3910
4739
|
try {
|
|
3911
4740
|
const { status } = request.body ?? {};
|
|
4741
|
+
const nextStatus = status ? toTaskStatus(status) : undefined;
|
|
4742
|
+
const tasks = await svc.readTasks(request.params.name);
|
|
4743
|
+
const existingTask = tasks.find((task) => task.id === request.params.id);
|
|
4744
|
+
if (isManualInProgressExitBlocked(existingTask?.status, nextStatus)) {
|
|
4745
|
+
return reply.code(409).send({
|
|
4746
|
+
ok: false,
|
|
4747
|
+
error: 'Agent 正在处理中,不能手动完成或取消。请等待 agent 调用 complete_task。',
|
|
4748
|
+
});
|
|
4749
|
+
}
|
|
3912
4750
|
const task = await svc.patchTask(request.params.name, request.params.id, {
|
|
3913
|
-
status:
|
|
4751
|
+
status: nextStatus,
|
|
3914
4752
|
});
|
|
4753
|
+
if (task.dispatchMeta && task.status === 'done') {
|
|
4754
|
+
await taskDispatch.onTaskCompleted(request.params.name, request.params.id).catch(() => {});
|
|
4755
|
+
}
|
|
3915
4756
|
return toTeamTask(task);
|
|
3916
4757
|
} catch {
|
|
3917
4758
|
return { ok: true };
|
|
@@ -3926,9 +4767,6 @@ app.patch<{ Params: { name: string; id: string }; Body: { owner?: string } }>(
|
|
|
3926
4767
|
const task = await svc.patchTask(request.params.name, request.params.id, {
|
|
3927
4768
|
assignee: body.owner ?? null,
|
|
3928
4769
|
});
|
|
3929
|
-
if (task.assignee) {
|
|
3930
|
-
svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
3931
|
-
}
|
|
3932
4770
|
return toTeamTask(task);
|
|
3933
4771
|
} catch {
|
|
3934
4772
|
return { ok: true };
|
|
@@ -3954,9 +4792,16 @@ app.post<{ Params: { name: string; id: string } }>(
|
|
|
3954
4792
|
'/api/teams/:name/tasks/:id/start',
|
|
3955
4793
|
async (request) => {
|
|
3956
4794
|
try {
|
|
4795
|
+
const existingTasks = await svc.readTasks(request.params.name);
|
|
4796
|
+
const existingTask = existingTasks.find((task) => task.id === request.params.id);
|
|
4797
|
+
if (existingTask?.dispatchMeta) {
|
|
4798
|
+
await taskDispatch.startDispatchedTask(request.params.name, request.params.id);
|
|
4799
|
+
return { notifiedOwner: true, crossTeamStarted: true };
|
|
4800
|
+
}
|
|
4801
|
+
|
|
3957
4802
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'doing' });
|
|
3958
4803
|
if (task.assignee) {
|
|
3959
|
-
svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
4804
|
+
await svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
3960
4805
|
return { notifiedOwner: true };
|
|
3961
4806
|
}
|
|
3962
4807
|
return { notifiedOwner: false };
|
|
@@ -3969,9 +4814,16 @@ app.post<{ Params: { name: string; id: string } }>(
|
|
|
3969
4814
|
'/api/teams/:name/tasks/:id/start-by-user',
|
|
3970
4815
|
async (request) => {
|
|
3971
4816
|
try {
|
|
4817
|
+
const existingTasks = await svc.readTasks(request.params.name);
|
|
4818
|
+
const existingTask = existingTasks.find((task) => task.id === request.params.id);
|
|
4819
|
+
if (existingTask?.dispatchMeta) {
|
|
4820
|
+
await taskDispatch.startDispatchedTask(request.params.name, request.params.id);
|
|
4821
|
+
return { notifiedOwner: true, crossTeamStarted: true };
|
|
4822
|
+
}
|
|
4823
|
+
|
|
3972
4824
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'doing' });
|
|
3973
4825
|
if (task.assignee) {
|
|
3974
|
-
svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
4826
|
+
await svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
3975
4827
|
return { notifiedOwner: true };
|
|
3976
4828
|
}
|
|
3977
4829
|
return { notifiedOwner: false };
|
|
@@ -3984,6 +4836,14 @@ app.post<{ Params: { name: string; id: string } }>(
|
|
|
3984
4836
|
'/api/teams/:name/tasks/:id/soft-delete',
|
|
3985
4837
|
async (request, reply) => {
|
|
3986
4838
|
try {
|
|
4839
|
+
const tasks = await svc.readTasks(request.params.name);
|
|
4840
|
+
const existingTask = tasks.find((task) => task.id === request.params.id);
|
|
4841
|
+
if (existingTask?.status === 'doing') {
|
|
4842
|
+
return reply.code(409).send({
|
|
4843
|
+
ok: false,
|
|
4844
|
+
error: 'Agent 正在处理中,不能手动删除任务。',
|
|
4845
|
+
});
|
|
4846
|
+
}
|
|
3987
4847
|
await svc.patchTask(request.params.name, request.params.id, {
|
|
3988
4848
|
status: 'done',
|
|
3989
4849
|
result: '__deleted__',
|
|
@@ -4078,18 +4938,17 @@ async function applyTeamConfigUpdate(
|
|
|
4078
4938
|
const providerRefs = Array.isArray(body.providerRefs)
|
|
4079
4939
|
? normalizeStringArray(body.providerRefs)
|
|
4080
4940
|
: undefined;
|
|
4081
|
-
const platformAllowFrom = body.platformAllowFrom
|
|
4082
|
-
|
|
4083
|
-
: undefined;
|
|
4084
|
-
const platformAllowChat = body.platformAllowChat
|
|
4085
|
-
? normalizePlatformAllowFrom(body.platformAllowChat)
|
|
4086
|
-
: undefined;
|
|
4941
|
+
const platformAllowFrom = normalizePlatformAllowUpdate(body.platformAllowFrom);
|
|
4942
|
+
const platformAllowChat = normalizePlatformAllowUpdate(body.platformAllowChat);
|
|
4087
4943
|
|
|
4088
|
-
// Validate agent type CLI availability
|
|
4944
|
+
// Validate agent type before checking CLI availability.
|
|
4945
|
+
if (agentType && !CC_AGENT_TYPES.includes(agentType as CcAgentType)) {
|
|
4946
|
+
throw new Error(`${agentType} 不是支持的运行时类型。`);
|
|
4947
|
+
}
|
|
4089
4948
|
if (agentType && agentType !== 'claudecode') {
|
|
4090
4949
|
try {
|
|
4091
|
-
const {
|
|
4092
|
-
|
|
4950
|
+
const { execFileSync } = await import('node:child_process');
|
|
4951
|
+
execFileSync('which', [agentType], { stdio: 'pipe', timeout: 5000 });
|
|
4093
4952
|
} catch {
|
|
4094
4953
|
throw new Error(
|
|
4095
4954
|
`${agentType} CLI 未安装,无法切换到 ${agentType} 模式。请先安装对应的 CLI 工具。`
|
|
@@ -4102,7 +4961,9 @@ async function applyTeamConfigUpdate(
|
|
|
4102
4961
|
if (description) localPatch.description = description;
|
|
4103
4962
|
if (color) localPatch.color = color;
|
|
4104
4963
|
if (agentType) localPatch.harness = agentType;
|
|
4105
|
-
if (workDir)
|
|
4964
|
+
if (workDir) {
|
|
4965
|
+
localPatch.workDir = workDir;
|
|
4966
|
+
}
|
|
4106
4967
|
if (permissionMode) localPatch.permissionMode = permissionMode;
|
|
4107
4968
|
if (language) localPatch.language = language;
|
|
4108
4969
|
if (managedSources) localPatch.managedSources = managedSources;
|
|
@@ -4152,6 +5013,7 @@ async function applyTeamConfigUpdate(
|
|
|
4152
5013
|
} catch {
|
|
4153
5014
|
bindProject = teamName;
|
|
4154
5015
|
}
|
|
5016
|
+
|
|
4155
5017
|
if (Object.keys(ccPatch).length > 0) {
|
|
4156
5018
|
try {
|
|
4157
5019
|
const updateResult = await cc.updateProject(
|
|
@@ -4159,17 +5021,25 @@ async function applyTeamConfigUpdate(
|
|
|
4159
5021
|
ccPatch as Parameters<CcConnectClient['updateProject']>[1]
|
|
4160
5022
|
);
|
|
4161
5023
|
if (updateResult.restart_required) {
|
|
4162
|
-
try {
|
|
5024
|
+
try {
|
|
5025
|
+
await cc.reload();
|
|
5026
|
+
} catch {
|
|
5027
|
+
/* best effort */
|
|
5028
|
+
}
|
|
4163
5029
|
}
|
|
4164
5030
|
} catch (err) {
|
|
4165
|
-
|
|
5031
|
+
if (!isCcProjectNotFoundError(err)) {
|
|
5032
|
+
ccSyncError = err instanceof Error ? err.message : String(err);
|
|
5033
|
+
}
|
|
4166
5034
|
}
|
|
4167
5035
|
}
|
|
4168
5036
|
if (providerRefs !== undefined) {
|
|
4169
5037
|
try {
|
|
4170
5038
|
await cc.setProviderRefs(bindProject, providerRefs);
|
|
4171
5039
|
} catch (err) {
|
|
4172
|
-
|
|
5040
|
+
if (!isCcProjectNotFoundError(err)) {
|
|
5041
|
+
ccSyncError = err instanceof Error ? err.message : String(err);
|
|
5042
|
+
}
|
|
4173
5043
|
}
|
|
4174
5044
|
}
|
|
4175
5045
|
|
|
@@ -4188,6 +5058,7 @@ async function applyTeamConfigUpdate(
|
|
|
4188
5058
|
replyFooter: replyFooter ?? false,
|
|
4189
5059
|
injectSender: injectSender ?? false,
|
|
4190
5060
|
platformAllowFrom: platformAllowFrom ?? {},
|
|
5061
|
+
platformAllowChat: platformAllowChat ?? {},
|
|
4191
5062
|
providerRefs: providerRefs ?? [],
|
|
4192
5063
|
ccSyncError,
|
|
4193
5064
|
};
|
|
@@ -4279,7 +5150,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/config', async (request,
|
|
|
4279
5150
|
return {
|
|
4280
5151
|
name,
|
|
4281
5152
|
color,
|
|
4282
|
-
projectPath: p.work_dir
|
|
5153
|
+
projectPath: p.work_dir || '',
|
|
4283
5154
|
description,
|
|
4284
5155
|
agentType: p.agent_type,
|
|
4285
5156
|
workDir: p.work_dir ?? '',
|
|
@@ -4303,6 +5174,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/config', async (request,
|
|
|
4303
5174
|
reply_footer: resolvedReplyFooter,
|
|
4304
5175
|
inject_sender: resolvedInjectSender,
|
|
4305
5176
|
platform_allow_from: resolvedPlatformAllowFrom,
|
|
5177
|
+
platform_allow_chat: resolvedPlatformAllowChat,
|
|
4306
5178
|
},
|
|
4307
5179
|
};
|
|
4308
5180
|
} catch {
|
|
@@ -4396,6 +5268,7 @@ app.post<{
|
|
|
4396
5268
|
{ deadlineMinutes: 10, needsHumanReview: true }
|
|
4397
5269
|
);
|
|
4398
5270
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
5271
|
+
broadcastSse('team-change', { type: 'task', teamName: targetTeam });
|
|
4399
5272
|
broadcastSse('collab-change', {
|
|
4400
5273
|
dispatchId: result.dispatchId,
|
|
4401
5274
|
status: result.status,
|
|
@@ -4404,14 +5277,15 @@ app.post<{
|
|
|
4404
5277
|
});
|
|
4405
5278
|
return {
|
|
4406
5279
|
ok: result.status !== 'failed',
|
|
4407
|
-
deliveredToInbox:
|
|
5280
|
+
deliveredToInbox: false,
|
|
4408
5281
|
messageId: sourceMsg.id,
|
|
4409
5282
|
dispatchId: result.dispatchId,
|
|
4410
5283
|
status: result.status,
|
|
4411
5284
|
message: result.message,
|
|
4412
5285
|
runtimeDelivery: {
|
|
4413
|
-
attempted:
|
|
4414
|
-
delivered:
|
|
5286
|
+
attempted: false,
|
|
5287
|
+
delivered: false,
|
|
5288
|
+
reason: 'waiting_for_target_start',
|
|
4415
5289
|
},
|
|
4416
5290
|
};
|
|
4417
5291
|
} catch (err) {
|
|
@@ -4429,6 +5303,7 @@ app.post<{
|
|
|
4429
5303
|
// 本地存储用户消息
|
|
4430
5304
|
const userMsg = await svc
|
|
4431
5305
|
.appendMessage(teamName, {
|
|
5306
|
+
id: msgId,
|
|
4432
5307
|
from: 'user',
|
|
4433
5308
|
to: teamName,
|
|
4434
5309
|
role: 'user',
|
|
@@ -4440,16 +5315,37 @@ app.post<{
|
|
|
4440
5315
|
// 广播 SSE 让前端触发消息刷新
|
|
4441
5316
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
4442
5317
|
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
5318
|
+
// Member DM: dispatch to the local claude CLI directly (bypass cc-connect). One
|
|
5319
|
+
// subprocess per member, resumed across messages. The reply streams back via the
|
|
5320
|
+
// manager event listener and is persisted on the turn's `result` event. cc-connect's
|
|
5321
|
+
// bridge stays reserved for external IM (Feishu/WeChat).
|
|
5322
|
+
const member = typeof request.body?.member === 'string' ? request.body.member.trim() : '';
|
|
5323
|
+
const directSessionKey = `${teamName}:member:${member || 'lead'}`;
|
|
5324
|
+
const memberWorkDir = await resolveDirectCliWorkDir(teamName).catch(() => '');
|
|
5325
|
+
const dispatchedDirect = Boolean(memberWorkDir);
|
|
5326
|
+
if (dispatchedDirect) {
|
|
5327
|
+
void dispatchDirectCliMessage({
|
|
5328
|
+
teamName,
|
|
5329
|
+
sessionKey: directSessionKey,
|
|
5330
|
+
workDir: memberWorkDir,
|
|
5331
|
+
from: member || teamName,
|
|
5332
|
+
to: 'user',
|
|
5333
|
+
text,
|
|
5334
|
+
// The agent reply needs its OWN id — distinct from the user message's
|
|
5335
|
+
// `msgId`. Reusing `msgId` persisted the reply with the user message's id,
|
|
5336
|
+
// colliding in the inbox so the renderer's id-keyed dedup dropped it
|
|
5337
|
+
// (the team-3ond "回复的没了" bug).
|
|
5338
|
+
messageId: buildDirectReplyMessageId(directSessionKey),
|
|
5339
|
+
}).catch((err) => {
|
|
5340
|
+
request.log.warn(
|
|
5341
|
+
{ err, teamName, sessionKey: directSessionKey },
|
|
5342
|
+
'send-message direct-cli delivery failed'
|
|
5343
|
+
);
|
|
5344
|
+
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
5345
|
+
});
|
|
5346
|
+
} else {
|
|
5347
|
+
request.log.warn({ teamName }, 'send-message direct-cli skipped: no work_dir resolved');
|
|
5348
|
+
}
|
|
4453
5349
|
|
|
4454
5350
|
return {
|
|
4455
5351
|
ok: true,
|
|
@@ -4457,7 +5353,7 @@ app.post<{
|
|
|
4457
5353
|
messageId: userMsg?.id ?? msgId,
|
|
4458
5354
|
runtimeDelivery: {
|
|
4459
5355
|
attempted: true,
|
|
4460
|
-
delivered:
|
|
5356
|
+
delivered: dispatchedDirect,
|
|
4461
5357
|
},
|
|
4462
5358
|
};
|
|
4463
5359
|
});
|
|
@@ -4469,8 +5365,16 @@ app.post<{
|
|
|
4469
5365
|
// requestReview: 前端调用 /tasks/:id/review,服务端原路由是 /tasks/:id/request-review
|
|
4470
5366
|
app.post<{ Params: { name: string; id: string } }>(
|
|
4471
5367
|
'/api/teams/:name/tasks/:id/review',
|
|
4472
|
-
async (request) => {
|
|
5368
|
+
async (request, reply) => {
|
|
4473
5369
|
try {
|
|
5370
|
+
const tasks = await svc.readTasks(request.params.name);
|
|
5371
|
+
const existingTask = tasks.find((task) => task.id === request.params.id);
|
|
5372
|
+
if (existingTask?.status === 'doing') {
|
|
5373
|
+
return reply.code(409).send({
|
|
5374
|
+
ok: false,
|
|
5375
|
+
error: 'Agent 正在处理中,不能手动提交审核。请等待 agent 调用 complete_task。',
|
|
5376
|
+
});
|
|
5377
|
+
}
|
|
4474
5378
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'done' });
|
|
4475
5379
|
return { ok: true, data: toTeamTask(task) };
|
|
4476
5380
|
} catch {
|
|
@@ -4590,6 +5494,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4590
5494
|
let inputTokens = 0;
|
|
4591
5495
|
let outputTokens = 0;
|
|
4592
5496
|
let cacheReadTokens = 0;
|
|
5497
|
+
let cacheCreationTokens = 0;
|
|
5498
|
+
let totalTokens = 0;
|
|
4593
5499
|
let messageCount = 0;
|
|
4594
5500
|
let totalDurationMs = 0;
|
|
4595
5501
|
|
|
@@ -4600,6 +5506,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4600
5506
|
inputTokens += s.inputTokens;
|
|
4601
5507
|
outputTokens += s.outputTokens;
|
|
4602
5508
|
cacheReadTokens += s.cacheReadTokens;
|
|
5509
|
+
cacheCreationTokens += s.cacheCreationTokens;
|
|
5510
|
+
totalTokens += s.totalTokens;
|
|
4603
5511
|
messageCount += s.messageCount;
|
|
4604
5512
|
|
|
4605
5513
|
if (s.startTime && (!earliestStart || s.startTime < earliestStart)) {
|
|
@@ -4633,6 +5541,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4633
5541
|
inputTokens,
|
|
4634
5542
|
outputTokens,
|
|
4635
5543
|
cacheReadTokens,
|
|
5544
|
+
cacheCreationTokens,
|
|
5545
|
+
totalTokens,
|
|
4636
5546
|
costUsd: 0,
|
|
4637
5547
|
tasksCompleted,
|
|
4638
5548
|
messageCount,
|
|
@@ -4650,6 +5560,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4650
5560
|
inputTokens: 0,
|
|
4651
5561
|
outputTokens: 0,
|
|
4652
5562
|
cacheReadTokens: 0,
|
|
5563
|
+
cacheCreationTokens: 0,
|
|
5564
|
+
totalTokens: 0,
|
|
4653
5565
|
costUsd: 0,
|
|
4654
5566
|
tasksCompleted: 0,
|
|
4655
5567
|
messageCount: 0,
|
|
@@ -4661,14 +5573,79 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4661
5573
|
}
|
|
4662
5574
|
);
|
|
4663
5575
|
|
|
4664
|
-
// tool-approval
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
}
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
5576
|
+
// tool-approval: write the user's Allow/Deny choice back to the subprocess as a
|
|
5577
|
+
// control_response, unblocking the turn so it can emit `result` and persist the reply.
|
|
5578
|
+
app.post<{
|
|
5579
|
+
Params: { name: string };
|
|
5580
|
+
Body: { runId?: unknown; requestId?: unknown; allow?: unknown; message?: unknown };
|
|
5581
|
+
}>('/api/teams/:name/tool-approval/respond', async (request, reply) => {
|
|
5582
|
+
const teamName = request.params.name;
|
|
5583
|
+
const requestId = typeof request.body?.requestId === 'string' ? request.body.requestId : '';
|
|
5584
|
+
const allow = request.body?.allow === true;
|
|
5585
|
+
const message =
|
|
5586
|
+
typeof request.body?.message === 'string' && request.body.message.trim()
|
|
5587
|
+
? request.body.message
|
|
5588
|
+
: undefined;
|
|
5589
|
+
if (!requestId) return reply.code(400).send({ ok: false, error: 'requestId required' });
|
|
5590
|
+
const pending = permissionSessionByRequestId.get(requestId);
|
|
5591
|
+
const sessionKey = pending?.sessionKey ?? `${teamName}:lead`;
|
|
5592
|
+
// AskUserQuestion: pass the user's answers via updatedInput so the CLI delivers them
|
|
5593
|
+
// without re-prompting (mirrors the multi-agent reference impl + --permission-prompt-tool spec).
|
|
5594
|
+
let updatedInput: Record<string, unknown> | undefined;
|
|
5595
|
+
if (allow && message && pending?.toolName === 'AskUserQuestion') {
|
|
5596
|
+
const toolInput = pending.toolInput ?? {};
|
|
5597
|
+
try {
|
|
5598
|
+
updatedInput = { ...toolInput, answers: JSON.parse(message) as Record<string, string> };
|
|
5599
|
+
} catch {
|
|
5600
|
+
// If message isn't JSON, use it as the answer to the first question.
|
|
5601
|
+
const questions = (toolInput.questions as { question?: string }[] | undefined) ?? [];
|
|
5602
|
+
const answers: Record<string, string> = {};
|
|
5603
|
+
if (questions[0]?.question) answers[questions[0].question] = message;
|
|
5604
|
+
updatedInput = { ...toolInput, answers };
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
try {
|
|
5608
|
+
directCliManager.respondPermission(sessionKey, requestId, allow, message, updatedInput);
|
|
5609
|
+
} catch (err) {
|
|
5610
|
+
app.log.warn({ err, sessionKey, requestId }, 'tool-approval respond failed');
|
|
5611
|
+
}
|
|
5612
|
+
permissionSessionByRequestId.delete(requestId);
|
|
5613
|
+
return { ok: true };
|
|
5614
|
+
});
|
|
5615
|
+
|
|
5616
|
+
// tool-approval: persist auto-allow settings per team (in-memory; renderer re-syncs on startup).
|
|
5617
|
+
app.post<{ Params: { name: string }; Body: Partial<ToolApprovalSettings> }>(
|
|
5618
|
+
'/api/teams/:name/tool-approval/settings',
|
|
5619
|
+
async (request) => {
|
|
5620
|
+
const teamName = request.params.name;
|
|
5621
|
+
const incoming = request.body ?? {};
|
|
5622
|
+
const prev = readToolApprovalSettings(teamName);
|
|
5623
|
+
toolApprovalSettingsByName.set(teamName, {
|
|
5624
|
+
autoAllowAll: incoming.autoAllowAll ?? prev.autoAllowAll,
|
|
5625
|
+
autoAllowFileEdits: incoming.autoAllowFileEdits ?? prev.autoAllowFileEdits,
|
|
5626
|
+
autoAllowSafeBash: incoming.autoAllowSafeBash ?? prev.autoAllowSafeBash,
|
|
5627
|
+
timeoutAction: incoming.timeoutAction ?? prev.timeoutAction,
|
|
5628
|
+
timeoutSeconds: incoming.timeoutSeconds ?? prev.timeoutSeconds,
|
|
5629
|
+
});
|
|
5630
|
+
return { ok: true };
|
|
5631
|
+
}
|
|
5632
|
+
);
|
|
5633
|
+
|
|
5634
|
+
// tool-approval: read a file for the Edit/Write diff preview. Local-first, best-effort —
|
|
5635
|
+
// errors return empty content so the approval sheet still renders without the diff.
|
|
5636
|
+
app.post<{ Body: { filePath?: unknown } }>(
|
|
5637
|
+
'/api/teams/tool-approval/read-file',
|
|
5638
|
+
async (request) => {
|
|
5639
|
+
const filePath = typeof request.body?.filePath === 'string' ? request.body.filePath : '';
|
|
5640
|
+
if (!filePath) return { content: '' };
|
|
5641
|
+
try {
|
|
5642
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
5643
|
+
return { content };
|
|
5644
|
+
} catch {
|
|
5645
|
+
return { content: '' };
|
|
5646
|
+
}
|
|
5647
|
+
}
|
|
5648
|
+
);
|
|
4672
5649
|
|
|
4673
5650
|
// validate-cli-args
|
|
4674
5651
|
app.post('/api/teams/validate-cli-args', async () => ({ valid: true, args: [], errors: [] }));
|
|
@@ -4727,6 +5704,110 @@ app.get<{ Querystring: { excludeTeam?: string } }>('/api/cross-team/targets', as
|
|
|
4727
5704
|
}));
|
|
4728
5705
|
});
|
|
4729
5706
|
|
|
5707
|
+
async function listDiscoverableWorkers(): Promise<DiscoverableWorker[]> {
|
|
5708
|
+
const teams = await taskDispatch.discoverTeams();
|
|
5709
|
+
return teams
|
|
5710
|
+
.filter((team) => team.slug !== SYSTEM_MANAGER_TEAM_NAME && team.location === 'local')
|
|
5711
|
+
.map(discoverableTeamToWorker)
|
|
5712
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
5713
|
+
}
|
|
5714
|
+
|
|
5715
|
+
app.get('/api/workers', async () => {
|
|
5716
|
+
return { workers: await listDiscoverableWorkers() };
|
|
5717
|
+
});
|
|
5718
|
+
|
|
5719
|
+
app.post<{
|
|
5720
|
+
Params: { workerId: string };
|
|
5721
|
+
Body: {
|
|
5722
|
+
fromTeam?: string;
|
|
5723
|
+
text?: unknown;
|
|
5724
|
+
summary?: unknown;
|
|
5725
|
+
sessionName?: unknown;
|
|
5726
|
+
reuse?: unknown;
|
|
5727
|
+
sessionKey?: unknown;
|
|
5728
|
+
};
|
|
5729
|
+
}>('/api/workers/:workerId/invoke', async (request, reply) => {
|
|
5730
|
+
try {
|
|
5731
|
+
const workerId = request.params.workerId.trim();
|
|
5732
|
+
const resolvedWorkerId = await resolveTeamSlugForMention(workerId);
|
|
5733
|
+
if (!resolvedWorkerId || resolvedWorkerId === SYSTEM_MANAGER_TEAM_NAME) {
|
|
5734
|
+
return reply.code(404).send({ error: `Unknown worker: ${workerId}` });
|
|
5735
|
+
}
|
|
5736
|
+
|
|
5737
|
+
const workers = await listDiscoverableWorkers();
|
|
5738
|
+
const worker = workers.find((entry) => entry.workerId === resolvedWorkerId);
|
|
5739
|
+
if (!worker) return reply.code(404).send({ error: `Unknown worker: ${workerId}` });
|
|
5740
|
+
|
|
5741
|
+
const message = typeof request.body?.text === 'string' ? request.body.text.trim() : '';
|
|
5742
|
+
if (!message) return reply.code(400).send({ error: 'text is required' });
|
|
5743
|
+
|
|
5744
|
+
const requestedSessionName =
|
|
5745
|
+
typeof request.body?.sessionName === 'string' ? request.body.sessionName.trim() : '';
|
|
5746
|
+
const summary = typeof request.body?.summary === 'string' ? request.body.summary.trim() : '';
|
|
5747
|
+
const sessionName =
|
|
5748
|
+
requestedSessionName ||
|
|
5749
|
+
summary ||
|
|
5750
|
+
`Admin Invoke ${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
5751
|
+
const reuse = request.body?.reuse !== false;
|
|
5752
|
+
const fromTeam = typeof request.body?.fromTeam === 'string' ? request.body.fromTeam.trim() : '';
|
|
5753
|
+
const fromSessionKey =
|
|
5754
|
+
typeof request.body?.sessionKey === 'string' && request.body.sessionKey.trim().length > 0
|
|
5755
|
+
? request.body.sessionKey.trim()
|
|
5756
|
+
: buildFallbackSessionKey(fromTeam || SYSTEM_MANAGER_TEAM_NAME);
|
|
5757
|
+
|
|
5758
|
+
const { bindProject } = await ensureLoopSessionProjectReady(resolvedWorkerId);
|
|
5759
|
+
const sessionKey = `${buildFallbackSessionKey(resolvedWorkerId)}:${Date.now().toString(36)}`;
|
|
5760
|
+
const sessions = reuse ? await cc.listSessions(bindProject).catch(() => []) : [];
|
|
5761
|
+
let session = reuse
|
|
5762
|
+
? sessions.find((item) => item.name === sessionName && (item.live || item.active))
|
|
5763
|
+
: undefined;
|
|
5764
|
+
const reused = Boolean(session);
|
|
5765
|
+
if (!session) {
|
|
5766
|
+
const created = await cc.createSession(bindProject, sessionName, sessionKey);
|
|
5767
|
+
session = {
|
|
5768
|
+
id: created.id,
|
|
5769
|
+
name: created.name || sessionName,
|
|
5770
|
+
session_key: created.session_key || sessionKey,
|
|
5771
|
+
agent_session_id: created.agent_session_id,
|
|
5772
|
+
agent_type: created.agent_type,
|
|
5773
|
+
active: created.active,
|
|
5774
|
+
live: created.live,
|
|
5775
|
+
history_count: created.history_count,
|
|
5776
|
+
created_at: created.created_at,
|
|
5777
|
+
updated_at: created.updated_at,
|
|
5778
|
+
last_message: null,
|
|
5779
|
+
platform: created.platform,
|
|
5780
|
+
};
|
|
5781
|
+
}
|
|
5782
|
+
|
|
5783
|
+
await sendHarnessMessageViaBridge({
|
|
5784
|
+
teamName: resolvedWorkerId,
|
|
5785
|
+
text: message,
|
|
5786
|
+
sessionKey: session.session_key,
|
|
5787
|
+
});
|
|
5788
|
+
if (fromTeam) {
|
|
5789
|
+
await svc.appendMessage(fromTeam, {
|
|
5790
|
+
from: `${fromTeam}.user`,
|
|
5791
|
+
to: resolvedWorkerId,
|
|
5792
|
+
role: 'user',
|
|
5793
|
+
content: `@${resolvedWorkerId} ${message}`,
|
|
5794
|
+
meta: { source: CROSS_TEAM_SENT_SOURCE, sessionKey: fromSessionKey, summary },
|
|
5795
|
+
});
|
|
5796
|
+
broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
|
|
5797
|
+
}
|
|
5798
|
+
broadcastSse('team-change', { type: 'inbox', teamName: resolvedWorkerId });
|
|
5799
|
+
return {
|
|
5800
|
+
ok: true,
|
|
5801
|
+
worker,
|
|
5802
|
+
session: mapCcSessionListItem(session, resolvedWorkerId),
|
|
5803
|
+
reused,
|
|
5804
|
+
messageSent: true,
|
|
5805
|
+
};
|
|
5806
|
+
} catch (err) {
|
|
5807
|
+
return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
|
|
5808
|
+
}
|
|
5809
|
+
});
|
|
5810
|
+
|
|
4730
5811
|
app.get<{ Params: { name: string } }>('/api/cross-team/outbox/:name', async (request) => {
|
|
4731
5812
|
const teamSlug = request.params.name;
|
|
4732
5813
|
const tasks = await svc.readTasks(teamSlug);
|
|
@@ -4881,11 +5962,6 @@ app.post<{
|
|
|
4881
5962
|
typeof sessionKey === 'string' && sessionKey.trim().length > 0
|
|
4882
5963
|
? sessionKey.trim()
|
|
4883
5964
|
: buildFallbackSessionKey(fromTeam);
|
|
4884
|
-
const toSessionKey = buildFallbackSessionKey(resolvedToTeam);
|
|
4885
|
-
const sentText = formatCrossTeamText(`${fromTeam}.${sender}`, depth, trimmedText, {
|
|
4886
|
-
conversationId: threadId,
|
|
4887
|
-
replyToConversationId,
|
|
4888
|
-
});
|
|
4889
5965
|
const meta = {
|
|
4890
5966
|
taskRefs,
|
|
4891
5967
|
actionMode,
|
|
@@ -4903,53 +5979,35 @@ app.post<{
|
|
|
4903
5979
|
meta: { ...meta, source: CROSS_TEAM_SENT_SOURCE, sessionKey: fromSessionKey },
|
|
4904
5980
|
});
|
|
4905
5981
|
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
source: CROSS_TEAM_SOURCE,
|
|
4914
|
-
relayOfMessageId: outgoing.id,
|
|
4915
|
-
sessionKey: toSessionKey,
|
|
4916
|
-
},
|
|
4917
|
-
});
|
|
4918
|
-
|
|
4919
|
-
const existingTasks = await svc.readTasks(resolvedToTeam).catch(() => []);
|
|
4920
|
-
const existingTask = existingTasks.find((task) => task.dispatchMeta?.dispatchId === threadId);
|
|
4921
|
-
if (!existingTask) {
|
|
4922
|
-
const now = new Date().toISOString();
|
|
4923
|
-
await svc.createTask(resolvedToTeam, {
|
|
4924
|
-
title: summary || trimmedText.split(/\r?\n/, 1)[0]?.slice(0, 120) || '跨团队 @ 消息',
|
|
5982
|
+
// Do not write the relayed message into the target inbox here. Cross-team
|
|
5983
|
+
// transfer must first create a target TODO/review surface; the target inbox or
|
|
5984
|
+
// runtime should only receive content after the user explicitly starts it.
|
|
5985
|
+
const dispatchResult = await taskDispatch.dispatchTask(
|
|
5986
|
+
fromTeam,
|
|
5987
|
+
{
|
|
5988
|
+
subject: summary || trimmedText.split(/\r?\n/, 1)[0]?.slice(0, 120) || '跨团队 @ 消息',
|
|
4925
5989
|
description: trimmedText,
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
receivedAt: now,
|
|
4934
|
-
},
|
|
4935
|
-
});
|
|
5990
|
+
prompt: trimmedText,
|
|
5991
|
+
},
|
|
5992
|
+
resolvedToTeam,
|
|
5993
|
+
{ dispatchId: threadId, needsHumanReview: needsHumanReview ?? true }
|
|
5994
|
+
);
|
|
5995
|
+
if (dispatchResult.status === 'failed') {
|
|
5996
|
+
return { ok: false, error: dispatchResult.message };
|
|
4936
5997
|
}
|
|
4937
5998
|
|
|
4938
5999
|
broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
|
|
4939
|
-
broadcastSse('team-change', { type: 'inbox', teamName: resolvedToTeam });
|
|
4940
6000
|
broadcastSse('team-change', { type: 'task', teamName: resolvedToTeam });
|
|
4941
6001
|
|
|
4942
|
-
void sendHarnessMessageViaBridge({
|
|
4943
|
-
teamName: resolvedToTeam,
|
|
4944
|
-
text: sentText,
|
|
4945
|
-
}).catch((err) => {
|
|
4946
|
-
request.log.warn({ err }, 'cross-team runtime delivery failed after persistence');
|
|
4947
|
-
});
|
|
4948
|
-
|
|
4949
6002
|
return {
|
|
4950
6003
|
messageId: outgoing.id,
|
|
4951
|
-
deliveredToInbox:
|
|
6004
|
+
deliveredToInbox: false,
|
|
4952
6005
|
deduplicated: false,
|
|
6006
|
+
runtimeDelivery: {
|
|
6007
|
+
attempted: false,
|
|
6008
|
+
delivered: false,
|
|
6009
|
+
reason: 'waiting_for_target_start',
|
|
6010
|
+
},
|
|
4953
6011
|
};
|
|
4954
6012
|
}
|
|
4955
6013
|
|
|
@@ -4968,21 +6026,6 @@ app.post<{
|
|
|
4968
6026
|
});
|
|
4969
6027
|
broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
|
|
4970
6028
|
|
|
4971
|
-
// Check collaboration toggle
|
|
4972
|
-
try {
|
|
4973
|
-
const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
|
|
4974
|
-
const raw = await fs.readFile(configPath, 'utf-8');
|
|
4975
|
-
const settings = JSON.parse(raw);
|
|
4976
|
-
if (!settings.taskBus?.collaboration) {
|
|
4977
|
-
return {
|
|
4978
|
-
ok: false,
|
|
4979
|
-
error: 'Distributed collaboration is not enabled. Enable it in Settings → Task Bus.',
|
|
4980
|
-
};
|
|
4981
|
-
}
|
|
4982
|
-
} catch {
|
|
4983
|
-
return { ok: false, error: 'Could not read task bus configuration.' };
|
|
4984
|
-
}
|
|
4985
|
-
|
|
4986
6029
|
const result = await taskDispatch.dispatchTask(
|
|
4987
6030
|
fromTeam ?? 'unknown',
|
|
4988
6031
|
{ subject, description, prompt },
|
|
@@ -4995,15 +6038,7 @@ app.post<{
|
|
|
4995
6038
|
const ok = result.status !== 'failed';
|
|
4996
6039
|
if (ok) {
|
|
4997
6040
|
broadcastSse('team-change', { type: 'inbox', teamName: resolvedToTeam });
|
|
4998
|
-
|
|
4999
|
-
teamName: resolvedToTeam,
|
|
5000
|
-
text: `[跨团队任务] ${fromTeam} 派发了任务:${subject}${description ? `\n\n${description}` : ''}`,
|
|
5001
|
-
}).catch((err) => {
|
|
5002
|
-
request.log.warn(
|
|
5003
|
-
{ err, fromTeam, resolvedToTeam },
|
|
5004
|
-
'cross-team task runtime delivery failed'
|
|
5005
|
-
);
|
|
5006
|
-
});
|
|
6041
|
+
broadcastSse('team-change', { type: 'task', teamName: resolvedToTeam });
|
|
5007
6042
|
}
|
|
5008
6043
|
return {
|
|
5009
6044
|
ok,
|
|
@@ -5122,18 +6157,171 @@ app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
|
|
|
5122
6157
|
return { ok: true, connected: false, message: 'Task bus disabled' };
|
|
5123
6158
|
});
|
|
5124
6159
|
|
|
6160
|
+
type TelemetryProjectRow = {
|
|
6161
|
+
cwd: string;
|
|
6162
|
+
displayName?: string;
|
|
6163
|
+
teamSlug?: string;
|
|
6164
|
+
bindProject?: string;
|
|
6165
|
+
sessions: number;
|
|
6166
|
+
messages: number;
|
|
6167
|
+
tokensIn: number;
|
|
6168
|
+
tokensOut: number;
|
|
6169
|
+
tokensTotal: number;
|
|
6170
|
+
};
|
|
6171
|
+
|
|
6172
|
+
type TelemetryStatusShape = {
|
|
6173
|
+
connected: boolean;
|
|
6174
|
+
lastScan: string | null;
|
|
6175
|
+
sessions: number;
|
|
6176
|
+
messages: number;
|
|
6177
|
+
tokensIn: number;
|
|
6178
|
+
tokensOut: number;
|
|
6179
|
+
cacheRead: number;
|
|
6180
|
+
cacheCreation: number;
|
|
6181
|
+
totalTokens: number;
|
|
6182
|
+
activeDays: number;
|
|
6183
|
+
hourly: number[];
|
|
6184
|
+
projects: TelemetryProjectRow[];
|
|
6185
|
+
workSecondsByDay: Record<string, number>;
|
|
6186
|
+
};
|
|
6187
|
+
|
|
6188
|
+
async function readTaskBusSettings(): Promise<TaskBusConfig> {
|
|
6189
|
+
const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
|
|
6190
|
+
let settings: Record<string, unknown> = {};
|
|
6191
|
+
try {
|
|
6192
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
6193
|
+
settings = JSON.parse(raw);
|
|
6194
|
+
} catch {
|
|
6195
|
+
// no settings
|
|
6196
|
+
}
|
|
6197
|
+
return (settings.taskBus ?? {}) as TaskBusConfig;
|
|
6198
|
+
}
|
|
6199
|
+
|
|
6200
|
+
async function enrichTelemetryProjectNames<T extends { projects: TelemetryProjectRow[] }>(
|
|
6201
|
+
status: T
|
|
6202
|
+
): Promise<T> {
|
|
6203
|
+
const teams = await svc.listTeams().catch(() => []);
|
|
6204
|
+
const byWorkDir = new Map<string, TeamManifest>();
|
|
6205
|
+
const byBindProject = new Map<string, TeamManifest>();
|
|
6206
|
+
for (const team of teams) {
|
|
6207
|
+
if (team.slug === SYSTEM_MANAGER_TEAM_NAME) continue;
|
|
6208
|
+
const workDir = (team.workDir || '').trim();
|
|
6209
|
+
if (workDir) byWorkDir.set(path.resolve(workDir), team);
|
|
6210
|
+
if (team.bindProject) byBindProject.set(team.bindProject, team);
|
|
6211
|
+
byBindProject.set(team.slug, team);
|
|
6212
|
+
}
|
|
6213
|
+
|
|
6214
|
+
return {
|
|
6215
|
+
...status,
|
|
6216
|
+
projects: status.projects.map((project) => {
|
|
6217
|
+
const cwd = (project.cwd || '').trim();
|
|
6218
|
+
const team =
|
|
6219
|
+
(cwd ? byWorkDir.get(path.resolve(cwd)) : undefined) ??
|
|
6220
|
+
byBindProject.get(cwd) ??
|
|
6221
|
+
byBindProject.get(path.basename(cwd));
|
|
6222
|
+
if (!team) return project;
|
|
6223
|
+
return {
|
|
6224
|
+
...project,
|
|
6225
|
+
displayName: team.displayName || team.slug,
|
|
6226
|
+
teamSlug: team.slug,
|
|
6227
|
+
bindProject: team.bindProject,
|
|
6228
|
+
};
|
|
6229
|
+
}),
|
|
6230
|
+
};
|
|
6231
|
+
}
|
|
6232
|
+
|
|
6233
|
+
function telemetryEmptyStatus(): TelemetryStatusShape {
|
|
6234
|
+
return {
|
|
6235
|
+
connected: false,
|
|
6236
|
+
lastScan: null,
|
|
6237
|
+
sessions: 0,
|
|
6238
|
+
messages: 0,
|
|
6239
|
+
tokensIn: 0,
|
|
6240
|
+
tokensOut: 0,
|
|
6241
|
+
cacheRead: 0,
|
|
6242
|
+
cacheCreation: 0,
|
|
6243
|
+
totalTokens: 0,
|
|
6244
|
+
activeDays: 0,
|
|
6245
|
+
hourly: [],
|
|
6246
|
+
projects: [],
|
|
6247
|
+
workSecondsByDay: {},
|
|
6248
|
+
};
|
|
6249
|
+
}
|
|
6250
|
+
|
|
6251
|
+
function csvCell(value: unknown): string {
|
|
6252
|
+
const text = String(value ?? '');
|
|
6253
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
6254
|
+
}
|
|
6255
|
+
|
|
6256
|
+
function buildUsageTelemetryExport(status: TelemetryStatusShape, format: 'csv' | 'json') {
|
|
6257
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
6258
|
+
if (format === 'json') {
|
|
6259
|
+
return {
|
|
6260
|
+
filename: `hermit-loop-usage-${stamp}.json`,
|
|
6261
|
+
mimeType: 'application/json;charset=utf-8',
|
|
6262
|
+
content: JSON.stringify(status, null, 2),
|
|
6263
|
+
};
|
|
6264
|
+
}
|
|
6265
|
+
|
|
6266
|
+
const rows = [
|
|
6267
|
+
[
|
|
6268
|
+
'section',
|
|
6269
|
+
'name',
|
|
6270
|
+
'sessions',
|
|
6271
|
+
'messages',
|
|
6272
|
+
'tokensIn',
|
|
6273
|
+
'tokensOut',
|
|
6274
|
+
'cacheRead',
|
|
6275
|
+
'cacheCreation',
|
|
6276
|
+
'totalTokens',
|
|
6277
|
+
'activeDays',
|
|
6278
|
+
'durationSeconds',
|
|
6279
|
+
'cwd',
|
|
6280
|
+
],
|
|
6281
|
+
[
|
|
6282
|
+
'summary',
|
|
6283
|
+
'累计 Loop 数据',
|
|
6284
|
+
status.sessions,
|
|
6285
|
+
status.messages,
|
|
6286
|
+
status.tokensIn,
|
|
6287
|
+
status.tokensOut,
|
|
6288
|
+
status.cacheRead,
|
|
6289
|
+
status.cacheCreation,
|
|
6290
|
+
status.totalTokens,
|
|
6291
|
+
status.activeDays,
|
|
6292
|
+
'',
|
|
6293
|
+
'',
|
|
6294
|
+
],
|
|
6295
|
+
...Object.entries(status.workSecondsByDay)
|
|
6296
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
6297
|
+
.map(([day, seconds]) => ['day', day, '', '', '', '', '', '', '', '', seconds, '']),
|
|
6298
|
+
...status.projects.map((project) => [
|
|
6299
|
+
'project',
|
|
6300
|
+
project.displayName || path.basename(project.cwd) || project.cwd,
|
|
6301
|
+
project.sessions,
|
|
6302
|
+
project.messages,
|
|
6303
|
+
project.tokensIn,
|
|
6304
|
+
project.tokensOut,
|
|
6305
|
+
'',
|
|
6306
|
+
'',
|
|
6307
|
+
project.tokensTotal,
|
|
6308
|
+
'',
|
|
6309
|
+
'',
|
|
6310
|
+
project.cwd,
|
|
6311
|
+
]),
|
|
6312
|
+
];
|
|
6313
|
+
|
|
6314
|
+
return {
|
|
6315
|
+
filename: `hermit-loop-usage-${stamp}.csv`,
|
|
6316
|
+
mimeType: 'text/csv;charset=utf-8',
|
|
6317
|
+
content: rows.map((row) => row.map(csvCell).join(',')).join('\n'),
|
|
6318
|
+
};
|
|
6319
|
+
}
|
|
6320
|
+
|
|
5125
6321
|
// POST /api/telemetry/scan → trigger manual scan
|
|
5126
6322
|
app.post('/api/telemetry/scan', async (request, reply) => {
|
|
5127
6323
|
try {
|
|
5128
|
-
const
|
|
5129
|
-
let settings: Record<string, unknown> = {};
|
|
5130
|
-
try {
|
|
5131
|
-
const raw = await fs.readFile(configPath, 'utf-8');
|
|
5132
|
-
settings = JSON.parse(raw);
|
|
5133
|
-
} catch {
|
|
5134
|
-
// no settings
|
|
5135
|
-
}
|
|
5136
|
-
const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
|
|
6324
|
+
const taskBus = await readTaskBusSettings();
|
|
5137
6325
|
if (!taskBus.telemetry?.enabled) {
|
|
5138
6326
|
return reply.code(400).send({ error: 'Telemetry is not enabled' });
|
|
5139
6327
|
}
|
|
@@ -5141,7 +6329,7 @@ app.post('/api/telemetry/scan', async (request, reply) => {
|
|
|
5141
6329
|
if (!result) {
|
|
5142
6330
|
return reply.code(503).send({ error: 'Telemetry scan failed' });
|
|
5143
6331
|
}
|
|
5144
|
-
return {
|
|
6332
|
+
return await enrichTelemetryProjectNames({
|
|
5145
6333
|
ok: true,
|
|
5146
6334
|
connected: taskBus.telemetry.uploadEnabled === true,
|
|
5147
6335
|
lastScan: new Date().toISOString(),
|
|
@@ -5151,16 +6339,35 @@ app.post('/api/telemetry/scan', async (request, reply) => {
|
|
|
5151
6339
|
tokensOut: result.aggregate.tokens.output,
|
|
5152
6340
|
cacheRead: result.aggregate.tokens.cacheRead,
|
|
5153
6341
|
cacheCreation: result.aggregate.tokens.cacheCreation,
|
|
6342
|
+
totalTokens: result.aggregate.tokens.total,
|
|
5154
6343
|
activeDays: result.aggregate.activeDays,
|
|
5155
6344
|
hourly: result.aggregate.hourly,
|
|
5156
6345
|
projects: result.aggregate.projects,
|
|
5157
6346
|
workSecondsByDay: result.aggregate.workSecondsByDay,
|
|
5158
|
-
};
|
|
6347
|
+
});
|
|
5159
6348
|
} catch (err) {
|
|
5160
6349
|
return reply.code(500).send({ error: String(err) });
|
|
5161
6350
|
}
|
|
5162
6351
|
});
|
|
5163
6352
|
|
|
6353
|
+
// GET /api/telemetry/export → export Loop usage telemetry summary/projects
|
|
6354
|
+
app.get<{ Querystring: { format?: 'csv' | 'json' | string } }>(
|
|
6355
|
+
'/api/telemetry/export',
|
|
6356
|
+
async (request, reply) => {
|
|
6357
|
+
try {
|
|
6358
|
+
const format = request.query.format === 'json' ? 'json' : 'csv';
|
|
6359
|
+
const taskBus = await readTaskBusSettings();
|
|
6360
|
+
const redisCfg = taskBus.enabled ? taskBus.redis : undefined;
|
|
6361
|
+
const status = await enrichTelemetryProjectNames(
|
|
6362
|
+
(await getTelemetryStatus(redisCfg)) ?? telemetryEmptyStatus()
|
|
6363
|
+
);
|
|
6364
|
+
return buildUsageTelemetryExport(status, format);
|
|
6365
|
+
} catch (err) {
|
|
6366
|
+
return reply.code(500).send({ error: String(err) });
|
|
6367
|
+
}
|
|
6368
|
+
}
|
|
6369
|
+
);
|
|
6370
|
+
|
|
5164
6371
|
// GET /api/telemetry/conversations → local Feishu/Lark conversation telemetry
|
|
5165
6372
|
app.get<{
|
|
5166
6373
|
Querystring: {
|
|
@@ -5258,48 +6465,12 @@ app.get<{
|
|
|
5258
6465
|
// GET /api/telemetry/status → current telemetry status (full stats)
|
|
5259
6466
|
app.get('/api/telemetry/status', async (request, reply) => {
|
|
5260
6467
|
try {
|
|
5261
|
-
const
|
|
5262
|
-
let settings: Record<string, unknown> = {};
|
|
5263
|
-
try {
|
|
5264
|
-
const raw = await fs.readFile(configPath, 'utf-8');
|
|
5265
|
-
settings = JSON.parse(raw);
|
|
5266
|
-
} catch {
|
|
5267
|
-
// no settings
|
|
5268
|
-
}
|
|
5269
|
-
const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
|
|
6468
|
+
const taskBus = await readTaskBusSettings();
|
|
5270
6469
|
const redisCfg = taskBus.enabled ? taskBus.redis : undefined;
|
|
5271
6470
|
const status = await getTelemetryStatus(redisCfg);
|
|
5272
|
-
return (
|
|
5273
|
-
status ?? {
|
|
5274
|
-
connected: false,
|
|
5275
|
-
lastScan: null,
|
|
5276
|
-
sessions: 0,
|
|
5277
|
-
messages: 0,
|
|
5278
|
-
tokensIn: 0,
|
|
5279
|
-
tokensOut: 0,
|
|
5280
|
-
cacheRead: 0,
|
|
5281
|
-
cacheCreation: 0,
|
|
5282
|
-
activeDays: 0,
|
|
5283
|
-
hourly: [],
|
|
5284
|
-
projects: [],
|
|
5285
|
-
workSecondsByDay: {},
|
|
5286
|
-
}
|
|
5287
|
-
);
|
|
6471
|
+
return await enrichTelemetryProjectNames(status ?? telemetryEmptyStatus());
|
|
5288
6472
|
} catch {
|
|
5289
|
-
return
|
|
5290
|
-
connected: false,
|
|
5291
|
-
lastScan: null,
|
|
5292
|
-
sessions: 0,
|
|
5293
|
-
messages: 0,
|
|
5294
|
-
tokensIn: 0,
|
|
5295
|
-
tokensOut: 0,
|
|
5296
|
-
cacheRead: 0,
|
|
5297
|
-
cacheCreation: 0,
|
|
5298
|
-
activeDays: 0,
|
|
5299
|
-
hourly: [],
|
|
5300
|
-
projects: [],
|
|
5301
|
-
workSecondsByDay: {},
|
|
5302
|
-
};
|
|
6473
|
+
return telemetryEmptyStatus();
|
|
5303
6474
|
}
|
|
5304
6475
|
});
|
|
5305
6476
|
|
|
@@ -5460,6 +6631,22 @@ app.post('/api/extensions/mcp/library/import', async (request) => {
|
|
|
5460
6631
|
return ext.mcpLibraryImport((request.body ?? {}) as any);
|
|
5461
6632
|
});
|
|
5462
6633
|
|
|
6634
|
+
app.get('/api/extensions/capability-packs', async () => {
|
|
6635
|
+
return ext.capabilityPacksList();
|
|
6636
|
+
});
|
|
6637
|
+
|
|
6638
|
+
app.post('/api/extensions/capability-packs/import', async (request) => {
|
|
6639
|
+
return ext.capabilityPacksImport((request.body ?? {}) as any);
|
|
6640
|
+
});
|
|
6641
|
+
|
|
6642
|
+
app.post('/api/extensions/capability-packs/export', async (request) => {
|
|
6643
|
+
return ext.capabilityPacksExport((request.body ?? {}) as any);
|
|
6644
|
+
});
|
|
6645
|
+
|
|
6646
|
+
app.post('/api/extensions/capability-packs/command-prompt', async (request) => {
|
|
6647
|
+
return ext.capabilityPacksCommandPrompt((request.body ?? {}) as any);
|
|
6648
|
+
});
|
|
6649
|
+
|
|
5463
6650
|
app.get('/api/extensions/skills', async (request) => {
|
|
5464
6651
|
const projectPath = (request.query as Record<string, string>).projectPath;
|
|
5465
6652
|
const result = await ext.skillsList(projectPath);
|
|
@@ -5653,6 +6840,7 @@ function reply500(err: unknown) {
|
|
|
5653
6840
|
// 启动 cc-connect Bridge WebSocket 连接(注册 platform=hermit adapter)
|
|
5654
6841
|
bridge.start();
|
|
5655
6842
|
await initializeTaskBusFromSettings();
|
|
6843
|
+
await ensureGlobalWorkflows();
|
|
5656
6844
|
|
|
5657
6845
|
try {
|
|
5658
6846
|
await app.listen({ host: HOST, port: PORT });
|
|
@@ -5671,6 +6859,7 @@ try {
|
|
|
5671
6859
|
// graceful shutdown
|
|
5672
6860
|
const shutdown = async () => {
|
|
5673
6861
|
try {
|
|
6862
|
+
directCliManager.shutdown();
|
|
5674
6863
|
bridge.dispose?.();
|
|
5675
6864
|
await app.close();
|
|
5676
6865
|
process.exit(0);
|
|
@@ -5680,3 +6869,6 @@ const shutdown = async () => {
|
|
|
5680
6869
|
};
|
|
5681
6870
|
process.on('SIGINT', shutdown);
|
|
5682
6871
|
process.on('SIGTERM', shutdown);
|
|
6872
|
+
// Sync backstop: reap direct-CLI subprocesses on any exit path that skips the async
|
|
6873
|
+
// shutdown (e.g. process killed without a delivered signal). child.kill() is synchronous.
|
|
6874
|
+
process.on('exit', () => directCliManager.shutdown());
|