@yancyyu/openhermit 1.6.42 → 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-DlFQ6mai.js → ProjectEditorOverlay-C98qSs7-.js} +1 -1
- package/dist-renderer/assets/{TeamGraphOverlay-D2TPMPGR.js → TeamGraphOverlay-CsBbZwcL.js} +1 -1
- package/dist-renderer/assets/{_basePickBy-Cmd0RHLQ.js → _basePickBy-ZOyLWjMK.js} +1 -1
- package/dist-renderer/assets/{_baseUniq-BI_iy8ea.js → _baseUniq-DBb726rt.js} +1 -1
- package/dist-renderer/assets/{arc-NzW2mjTP.js → arc-CdiTaR_R.js} +1 -1
- package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-Bzq85AYv.js → architectureDiagram-VXUJARFQ-Cz3sc5TH.js} +1 -1
- package/dist-renderer/assets/{blockDiagram-VD42YOAC-D1PvYS-b.js → blockDiagram-VD42YOAC-DE4c-KJ3.js} +1 -1
- package/dist-renderer/assets/{c4Diagram-YG6GDRKO-D49RKzPC.js → c4Diagram-YG6GDRKO-CmTMDTrV.js} +1 -1
- package/dist-renderer/assets/channel-KTpqi9eT.js +1 -0
- package/dist-renderer/assets/{chunk-4BX2VUAB-fmI_MQmQ.js → chunk-4BX2VUAB-rhHy3tFl.js} +1 -1
- package/dist-renderer/assets/{chunk-55IACEB6-Xsv9RCXZ.js → chunk-55IACEB6-fLZBzuo_.js} +1 -1
- package/dist-renderer/assets/{chunk-B4BG7PRW-BE1KO8Um.js → chunk-B4BG7PRW-DOzxQhim.js} +1 -1
- package/dist-renderer/assets/{chunk-DI55MBZ5-tqJ7Mv7f.js → chunk-DI55MBZ5-COQCcXC5.js} +1 -1
- package/dist-renderer/assets/{chunk-FMBD7UC4-DMD45MVJ.js → chunk-FMBD7UC4-IKU9U_Y4.js} +1 -1
- package/dist-renderer/assets/{chunk-QN33PNHL-DOhGrz-q.js → chunk-QN33PNHL-D6WV154X.js} +1 -1
- package/dist-renderer/assets/{chunk-QZHKN3VN-D8yDgJdD.js → chunk-QZHKN3VN-D90_2DQp.js} +1 -1
- package/dist-renderer/assets/{chunk-TZMSLE5B-BcsEDu7A.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-DlSqGHMX.js → cose-bilkent-S5V4N54A-6WiK6U2P.js} +1 -1
- package/dist-renderer/assets/{dagre-6UL2VRFP-BTT9tSAx.js → dagre-6UL2VRFP-DF4MMuTn.js} +1 -1
- package/dist-renderer/assets/{diagram-PSM6KHXK-Du-U-mK2.js → diagram-PSM6KHXK-CcF1eZ7E.js} +1 -1
- package/dist-renderer/assets/{diagram-QEK2KX5R-jFdHeKas.js → diagram-QEK2KX5R-DYlOVPQB.js} +1 -1
- package/dist-renderer/assets/{diagram-S2PKOQOG-DKLNK2bu.js → diagram-S2PKOQOG-BHXWsZOP.js} +1 -1
- package/dist-renderer/assets/{erDiagram-Q2GNP2WA-CZxHgIIo.js → erDiagram-Q2GNP2WA-GjmuBx8d.js} +1 -1
- package/dist-renderer/assets/{flowDiagram-NV44I4VS-v4XStCD0.js → flowDiagram-NV44I4VS-BuS7YVHk.js} +1 -1
- package/dist-renderer/assets/{ganttDiagram-JELNMOA3-DJjD_BEL.js → ganttDiagram-JELNMOA3-3Teu5tAa.js} +1 -1
- package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-BNy-jr03.js → gitGraphDiagram-V2S2FVAM-BiLdCYu5.js} +1 -1
- package/dist-renderer/assets/{graph-DDTrn6je.js → graph-CDP_R8ct.js} +1 -1
- package/dist-renderer/assets/{index-BBp78BAu.js → index-BSZdT-g-.js} +1 -1
- package/dist-renderer/assets/{index-eotrJaYy.js → index-BhWvMqsz.js} +1 -1
- package/dist-renderer/assets/{index-D8_B-cfs.js → index-C2_AupSj.js} +1 -1
- package/dist-renderer/assets/{index-BQrwHZ-k.js → index-C5ujiwAR.js} +580 -588
- package/dist-renderer/assets/index-CIS2CTK9.css +1 -0
- package/dist-renderer/assets/{index-CRKQSG9S.js → index-CVNjLwkq.js} +1 -1
- package/dist-renderer/assets/{index-DR6Wz52b.js → index-CwG3se0q.js} +1 -1
- package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DqnOsuza.js → infoDiagram-HS3SLOUP-DLHUFo72.js} +1 -1
- package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-DTobaO1d.js → journeyDiagram-XKPGCS4Q-BE07RpJD.js} +1 -1
- package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-HbwVOvWc.js → kanban-definition-3W4ZIXB7-DDHZy4NB.js} +1 -1
- package/dist-renderer/assets/{layout--VYmTcw2.js → layout-5nA5wUxO.js} +1 -1
- package/dist-renderer/assets/{linear-BsJh89Mr.js → linear-BtF1i2qN.js} +1 -1
- package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-BZqUZePd.js → mindmap-definition-VGOIOE7T-Z1Ui9Sqy.js} +1 -1
- package/dist-renderer/assets/{pieDiagram-ADFJNKIX-B1q_nH6P.js → pieDiagram-ADFJNKIX-LCjxckWv.js} +1 -1
- package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-UD8QhSEu.js → quadrantDiagram-AYHSOK5B-BOwKjSco.js} +1 -1
- package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-BA_i7Nw8.js → requirementDiagram-UZGBJVZJ-pChP8Znd.js} +1 -1
- package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-CMTnX-2d.js → sankeyDiagram-TZEHDZUN-DifZ2qpo.js} +1 -1
- package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BQXDB615.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-BAsPXy6X.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-BdasmVkC.js → timeline-definition-IT6M3QCI-CPgokIo8.js} +1 -1
- package/dist-renderer/assets/{treemap-GDKQZRPO-BkKQqIui.js → treemap-GDKQZRPO-DAVqSR9L.js} +1 -1
- package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-EAlPHOdx.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 +1709 -534
- 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 +679 -74
- 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 +115 -37
- 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 +143 -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 +156 -83
- 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 +41 -5
- 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-Ch7JrfUu.js +0 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-z9I4AnFy.js +0 -1
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-z9I4AnFy.js +0 -1
- package/dist-renderer/assets/clone-Dfi1Jx6l.js +0 -1
- package/dist-renderer/assets/index-iyjkpSus.css +0 -32
- package/dist-renderer/assets/stateDiagram-v2-4FDKWEC3-DTUIBfce.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,28 +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';
|
|
62
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
ensureGlobalWorkflows,
|
|
77
|
+
seedBuiltinWorkflows,
|
|
78
|
+
} from './services/system-manager/BuiltinWorkflowSeeder';
|
|
63
79
|
import {
|
|
64
80
|
SYSTEM_MANAGER_BIND_PROJECT,
|
|
65
81
|
SYSTEM_MANAGER_DISPLAY_NAME,
|
|
66
82
|
SYSTEM_MANAGER_TEAM_NAME,
|
|
67
83
|
} from '@shared/types/team';
|
|
68
|
-
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';
|
|
69
94
|
import type { TeamManifest } from './services/teams-mvp/TeamWorkspaceService';
|
|
70
95
|
import { UpdateService } from './services/UpdateService';
|
|
71
96
|
import {
|
|
@@ -79,6 +104,18 @@ import {
|
|
|
79
104
|
shouldIncludeContent,
|
|
80
105
|
} from './services/session-intelligence/ConversationTelemetryService';
|
|
81
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';
|
|
82
119
|
|
|
83
120
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
84
121
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'));
|
|
@@ -103,7 +140,7 @@ const CC_AGENT_TYPES: readonly CcAgentType[] = [
|
|
|
103
140
|
'tmux',
|
|
104
141
|
];
|
|
105
142
|
const SYSTEM_MANAGER_DESCRIPTION =
|
|
106
|
-
'项目级 Claude Code
|
|
143
|
+
'项目级 Claude Code Helm Loop,负责插件、MCP、Env、数字员工和统计数据的托管管理。';
|
|
107
144
|
|
|
108
145
|
function toCcAgentType(value: string | undefined): CcAgentType {
|
|
109
146
|
return CC_AGENT_TYPES.includes(value as CcAgentType) ? (value as CcAgentType) : 'claudecode';
|
|
@@ -189,9 +226,10 @@ function loadConfig(): HermitConfig {
|
|
|
189
226
|
merged = { ...defaults, ...raw };
|
|
190
227
|
}
|
|
191
228
|
} catch (err) {
|
|
192
|
-
const msg =
|
|
193
|
-
|
|
194
|
-
|
|
229
|
+
const msg =
|
|
230
|
+
err instanceof SyntaxError
|
|
231
|
+
? `${HERMIT_CONFIG_FILE} 格式错误: ${err.message}。将使用默认配置并覆盖修复。`
|
|
232
|
+
: `读取 ${HERMIT_CONFIG_FILE} 失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
195
233
|
console.warn(`[Hermit] ${msg}`);
|
|
196
234
|
// Auto-heal: rewrite the config file with valid defaults + any readable env overrides
|
|
197
235
|
mkdirSync(HERMIT_HOME, { recursive: true });
|
|
@@ -230,7 +268,9 @@ function writeHermitConfigRaw(content: string): HermitConfig {
|
|
|
230
268
|
parsed = JSON.parse(content);
|
|
231
269
|
} catch (err) {
|
|
232
270
|
if (err instanceof SyntaxError) {
|
|
233
|
-
throw new Error(
|
|
271
|
+
throw new Error(
|
|
272
|
+
`配置文件 JSON 格式错误: ${err.message}。请检查是否有尾逗号、单引号或注释等非法 JSON 语法。`
|
|
273
|
+
);
|
|
234
274
|
}
|
|
235
275
|
throw err;
|
|
236
276
|
}
|
|
@@ -255,31 +295,21 @@ const bridge = new CcConnectBridge({
|
|
|
255
295
|
bridgeUrl: runtimeConfig.ccBridgeUrl,
|
|
256
296
|
bridgeToken: runtimeConfig.ccBridgeToken || runtimeConfig.ccToken,
|
|
257
297
|
});
|
|
258
|
-
const svc = new TeamProvisioningService(cc, bridge
|
|
298
|
+
const svc = new TeamProvisioningService(cc, bridge, undefined, {
|
|
299
|
+
restartCcConnect: restartCcConnectAndReconnectBridge,
|
|
300
|
+
});
|
|
259
301
|
const systemManagerConfig = new SystemManagerConfigService(REPO_ROOT);
|
|
260
|
-
const systemManagerPty = new SystemManagerPtyService();
|
|
261
302
|
const workflowPromptService = new WorkflowPromptService();
|
|
262
303
|
|
|
263
|
-
systemManagerPty.on('data', (event) => broadcastSse('terminal:data', event));
|
|
264
|
-
systemManagerPty.on('exit', (event) => broadcastSse('terminal:exit', event));
|
|
265
|
-
|
|
266
304
|
async function getSystemManagerWorkDir(): Promise<string> {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
const manifest = await svc.readTeamManifest(SYSTEM_MANAGER_TEAM_NAME);
|
|
277
|
-
if (manifest.workDir !== workDir) {
|
|
278
|
-
await svc.updateTeam(SYSTEM_MANAGER_TEAM_NAME, { workDir });
|
|
279
|
-
}
|
|
280
|
-
} catch {
|
|
281
|
-
// The console team may not exist yet; ensureSystemManager() will create it later.
|
|
282
|
-
}
|
|
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;
|
|
283
313
|
}
|
|
284
314
|
|
|
285
315
|
let systemManagerEnsurePromise: Promise<SystemManagerSummary> | null = null;
|
|
@@ -349,52 +379,142 @@ async function ensureSystemManager(): Promise<SystemManagerSummary> {
|
|
|
349
379
|
return systemManagerEnsurePromise;
|
|
350
380
|
}
|
|
351
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
|
+
|
|
352
423
|
const conversationTelemetry = new ConversationTelemetryService({
|
|
353
424
|
cc,
|
|
354
425
|
listTeams: () => svc.listTeams(),
|
|
355
426
|
readTeamManifest: (teamName) => svc.readTeamManifest(teamName),
|
|
356
427
|
});
|
|
357
428
|
const localSessionScanner = new LocalSessionScanner();
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
let tokens = 0;
|
|
367
|
-
let messages = 0;
|
|
368
|
-
let earliest: string | null = null;
|
|
369
|
-
let latest: string | null = null;
|
|
370
|
-
for (const s of sessions) {
|
|
371
|
-
tokens += s.inputTokens + s.outputTokens;
|
|
372
|
-
messages += s.messageCount;
|
|
373
|
-
if (s.startTime && (!earliest || s.startTime < earliest)) earliest = s.startTime;
|
|
374
|
-
if (s.endTime && (!latest || s.endTime > latest)) latest = s.endTime;
|
|
375
|
-
}
|
|
376
|
-
let durationMs = 0;
|
|
377
|
-
if (earliest && latest) {
|
|
378
|
-
durationMs = Date.parse(latest) - Date.parse(earliest);
|
|
379
|
-
if (durationMs < 0) durationMs = 0;
|
|
380
|
-
}
|
|
381
|
-
return { sessions: sessions.length, messages, tokens, durationMs };
|
|
382
|
-
} catch {
|
|
383
|
-
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>;
|
|
384
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;
|
|
385
469
|
}
|
|
386
470
|
|
|
387
471
|
async function resolveRouteCcProjectName(teamName: string): Promise<string> {
|
|
388
|
-
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);
|
|
389
498
|
}
|
|
390
499
|
|
|
391
500
|
const collabBoard = new CollaborationBoardService();
|
|
392
501
|
const taskDispatch = new TaskDispatchService(svc['workspace'], collabBoard);
|
|
393
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
|
+
|
|
394
511
|
// Broadcast collab board changes via SSE
|
|
395
512
|
taskDispatch.onCollabChange = (dispatchId, status, fromTeam, toTeam) => {
|
|
396
513
|
broadcastSse('collab-change', { dispatchId, status, fromTeam, toTeam });
|
|
397
514
|
};
|
|
515
|
+
taskDispatch.onRuntimeStart = async ({ teamName, text }) => {
|
|
516
|
+
await sendHarnessMessageViaBridge({ teamName, text });
|
|
517
|
+
};
|
|
398
518
|
|
|
399
519
|
async function readSavedTaskBusConfig(): Promise<TaskBusConfig | null> {
|
|
400
520
|
try {
|
|
@@ -470,6 +590,94 @@ function normalizePlatformAllowFrom(value: unknown): Record<string, string> {
|
|
|
470
590
|
return Object.fromEntries(entries);
|
|
471
591
|
}
|
|
472
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
|
+
|
|
473
681
|
// ===========================================================================
|
|
474
682
|
// SSE 客户端管理器 — 广播 bridge 事件到所有连接的前端客户端
|
|
475
683
|
// ===========================================================================
|
|
@@ -491,11 +699,148 @@ function broadcastSse(eventName: string, data: unknown): void {
|
|
|
491
699
|
// 启动 bridge 并把事件广播到 SSE 客户端
|
|
492
700
|
bridge.start();
|
|
493
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
|
+
|
|
494
838
|
bridge.on('reply', (msg) => {
|
|
495
839
|
const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
|
|
496
|
-
const teamName = resolveTeamFromSessionKey(sessionKey) ?? sessionKey;
|
|
497
840
|
|
|
498
841
|
void (async () => {
|
|
842
|
+
const teamName = await resolveTeamFromBridgeMessageWithRetry(msg);
|
|
843
|
+
if (!teamName) return;
|
|
499
844
|
// 先落盘再广播,否则前端可能在 appendFile 完成前刷新到旧 feed。
|
|
500
845
|
await svc.appendMessage(teamName, {
|
|
501
846
|
from: teamName,
|
|
@@ -506,19 +851,20 @@ bridge.on('reply', (msg) => {
|
|
|
506
851
|
});
|
|
507
852
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
508
853
|
})().catch((err) => {
|
|
509
|
-
app.log.warn({ err,
|
|
854
|
+
app.log.warn({ err, sessionKey }, 'bridge reply persistence failed');
|
|
510
855
|
});
|
|
511
856
|
});
|
|
512
857
|
|
|
513
858
|
bridge.on('reply_stream', (msg) => {
|
|
514
859
|
const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
|
|
515
|
-
const teamName = resolveTeamFromSessionKey(sessionKey) ?? sessionKey;
|
|
516
860
|
const done = (msg as { done?: boolean }).done ?? false;
|
|
517
861
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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 ?? '';
|
|
522
868
|
if (fullText) {
|
|
523
869
|
await svc.appendMessage(teamName, {
|
|
524
870
|
from: teamName,
|
|
@@ -529,43 +875,148 @@ bridge.on('reply_stream', (msg) => {
|
|
|
529
875
|
});
|
|
530
876
|
}
|
|
531
877
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
});
|
|
535
|
-
} else {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
536
880
|
broadcastSse('team-change', { type: 'lead-message', teamName });
|
|
537
|
-
}
|
|
881
|
+
})().catch((err) => {
|
|
882
|
+
app.log.warn({ err, sessionKey }, 'bridge stream reply persistence failed');
|
|
883
|
+
});
|
|
538
884
|
});
|
|
539
885
|
|
|
540
886
|
bridge.on('message', (msg) => {
|
|
541
887
|
const type = (msg as { type?: string }).type ?? '';
|
|
542
888
|
const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
|
|
543
889
|
if (!sessionKey) return; // 无 session_key 的控制帧(pong 等)不广播
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
+
});
|
|
549
900
|
});
|
|
550
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
|
+
|
|
551
907
|
/**
|
|
552
|
-
* 从 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。
|
|
553
1008
|
* 约定格式:
|
|
554
1009
|
* hermit:{teamName}:session (老格式)
|
|
555
1010
|
* hermit:{teamName}:lead (新格式)
|
|
556
1011
|
* bridge:hermit-{team}:{member}
|
|
557
|
-
* {teamName} (直接就是 teamName)
|
|
558
1012
|
*/
|
|
559
|
-
function
|
|
1013
|
+
function parseHermitTeamFromSessionKey(sessionKey: string): string | null {
|
|
560
1014
|
if (!sessionKey) return null;
|
|
561
|
-
// hermit:{teamName}:xxx
|
|
562
1015
|
const hermitMatch = sessionKey.match(/^hermit:([^:]+):/);
|
|
563
1016
|
if (hermitMatch) return hermitMatch[1];
|
|
564
|
-
// bridge:hermit-{team}:{member}
|
|
565
1017
|
const bridgeMatch = sessionKey.match(/^bridge:hermit-([^:]+):/);
|
|
566
1018
|
if (bridgeMatch) return bridgeMatch[1];
|
|
567
|
-
|
|
568
|
-
return sessionKey;
|
|
1019
|
+
return null;
|
|
569
1020
|
}
|
|
570
1021
|
|
|
571
1022
|
const app = Fastify({
|
|
@@ -591,9 +1042,22 @@ const allowedCorsOrigins = configuredCorsOrigins?.length
|
|
|
591
1042
|
];
|
|
592
1043
|
const allowedOriginSet = new Set(allowedCorsOrigins);
|
|
593
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
|
+
|
|
594
1057
|
function isTrustedBrowserOrigin(origin: string | undefined): boolean {
|
|
595
1058
|
if (!origin) return true;
|
|
596
|
-
|
|
1059
|
+
if (allowedOriginSet.has(origin)) return true;
|
|
1060
|
+
return isLoopbackBrowserOrigin(origin);
|
|
597
1061
|
}
|
|
598
1062
|
|
|
599
1063
|
function assertTrustedBrowserOrigin(request: import('fastify').FastifyRequest): void {
|
|
@@ -661,17 +1125,15 @@ async function proxyToCcConnect(
|
|
|
661
1125
|
);
|
|
662
1126
|
return reply.code(upstream.status).send({
|
|
663
1127
|
ok: false,
|
|
664
|
-
error:
|
|
1128
|
+
error:
|
|
1129
|
+
`cc-connect 端点 ${subPath} 返回了非 JSON 响应 (HTTP ${upstream.status})。` +
|
|
665
1130
|
'请检查 cc-connect 是否正在运行且支持该端点。',
|
|
666
1131
|
});
|
|
667
1132
|
}
|
|
668
1133
|
|
|
669
1134
|
return reply
|
|
670
1135
|
.code(upstream.status)
|
|
671
|
-
.header(
|
|
672
|
-
'Content-Type',
|
|
673
|
-
contentType || 'application/json; charset=utf-8'
|
|
674
|
-
)
|
|
1136
|
+
.header('Content-Type', contentType || 'application/json; charset=utf-8')
|
|
675
1137
|
.send(body);
|
|
676
1138
|
}
|
|
677
1139
|
|
|
@@ -991,18 +1453,8 @@ app.patch<{ Body: Record<string, unknown> }>('/api/cc-settings', async (request)
|
|
|
991
1453
|
// restart / reload cc-connect
|
|
992
1454
|
app.post('/api/cc-restart', async () => {
|
|
993
1455
|
try {
|
|
994
|
-
await
|
|
995
|
-
|
|
996
|
-
for (let i = 0; i < 30; i++) {
|
|
997
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
998
|
-
try {
|
|
999
|
-
await cc.listProjects();
|
|
1000
|
-
return { ok: true };
|
|
1001
|
-
} catch {
|
|
1002
|
-
/* not back yet */
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
return reply500(new Error('cc-connect did not come back within 30s'));
|
|
1456
|
+
await restartCcConnectAndReconnectBridge();
|
|
1457
|
+
return { ok: true };
|
|
1006
1458
|
} catch (err) {
|
|
1007
1459
|
return reply500(err);
|
|
1008
1460
|
}
|
|
@@ -1021,10 +1473,14 @@ app.post('/api/cc-reload', async () => {
|
|
|
1021
1473
|
// Teams — cc-connect projects 即团队,本地 ~/.hermit/teams/ 仅存 tasks + 额外元数据
|
|
1022
1474
|
// ===========================================================================
|
|
1023
1475
|
|
|
1024
|
-
// POST /api/system-manager/ensure →
|
|
1476
|
+
// POST /api/system-manager/ensure → 确保项目级 Helm Loop存在
|
|
1025
1477
|
app.post('/api/system-manager/ensure', async (_request, reply) => {
|
|
1026
1478
|
try {
|
|
1027
|
-
|
|
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;
|
|
1028
1484
|
} catch (err) {
|
|
1029
1485
|
return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1030
1486
|
}
|
|
@@ -1041,12 +1497,10 @@ app.get('/api/system-manager/status', async (_request, reply) => {
|
|
|
1041
1497
|
app.get('/api/system-manager/config', async (_request, reply) => {
|
|
1042
1498
|
try {
|
|
1043
1499
|
const config = await systemManagerConfig.getConfig();
|
|
1044
|
-
// Seed builtin
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
if (workspaceWorkflows) {
|
|
1049
|
-
void seedBuiltinWorkflows(workspaceWorkflows);
|
|
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);
|
|
1050
1504
|
}
|
|
1051
1505
|
return config;
|
|
1052
1506
|
} catch (err) {
|
|
@@ -1059,7 +1513,7 @@ app.put<{ Body: { selectedWorkDir?: string; workflowFolder?: string | null } }>(
|
|
|
1059
1513
|
async (request, reply) => {
|
|
1060
1514
|
try {
|
|
1061
1515
|
const config = await systemManagerConfig.updateConfig(request.body ?? {});
|
|
1062
|
-
await
|
|
1516
|
+
await seedBuiltinWorkflows(config.selectedWorkDir);
|
|
1063
1517
|
return config;
|
|
1064
1518
|
} catch (err) {
|
|
1065
1519
|
return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) });
|
|
@@ -1079,126 +1533,157 @@ app.post<{ Body: { folder?: string } }>(
|
|
|
1079
1533
|
const result = await workflowPromptService.list(folder);
|
|
1080
1534
|
await systemManagerConfig.updateConfig({ workflowFolder: result.folder });
|
|
1081
1535
|
return result;
|
|
1082
|
-
} 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
|
+
}
|
|
1083
1541
|
return { folder: '', prompts: [], warnings: [] };
|
|
1084
1542
|
}
|
|
1085
1543
|
}
|
|
1086
1544
|
);
|
|
1087
1545
|
|
|
1088
|
-
app.post<{ Body: { id?: string } }>(
|
|
1546
|
+
app.post<{ Body: { folder?: string; id?: string } }>(
|
|
1089
1547
|
'/api/system-manager/workflows/read',
|
|
1090
1548
|
async (request, reply) => {
|
|
1091
1549
|
try {
|
|
1092
1550
|
assertTrustedBrowserOrigin(request);
|
|
1093
1551
|
const config = await systemManagerConfig.getConfig();
|
|
1094
|
-
|
|
1095
|
-
|
|
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' });
|
|
1096
1557
|
const id = typeof request.body?.id === 'string' ? request.body.id : '';
|
|
1097
|
-
return await workflowPromptService.read(
|
|
1558
|
+
return await workflowPromptService.read(folder, id);
|
|
1098
1559
|
} catch (err) {
|
|
1099
1560
|
return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1100
1561
|
}
|
|
1101
1562
|
}
|
|
1102
1563
|
);
|
|
1103
1564
|
|
|
1104
|
-
|
|
1105
|
-
'/
|
|
1106
|
-
|
|
1107
|
-
try {
|
|
1108
|
-
assertTrustedBrowserOrigin(request);
|
|
1109
|
-
const requestedCwd = typeof request.body?.cwd === 'string' ? request.body.cwd.trim() : '';
|
|
1110
|
-
const command = typeof request.body?.command === 'string' ? request.body.command : 'claude';
|
|
1111
|
-
const args = Array.isArray(request.body?.args) ? request.body.args : [];
|
|
1112
|
-
|
|
1113
|
-
// Use requested cwd if provided; otherwise fall back to system manager config
|
|
1114
|
-
let cwd: string;
|
|
1115
|
-
if (requestedCwd) {
|
|
1116
|
-
cwd = requestedCwd;
|
|
1117
|
-
// Update system manager config to track the work dir
|
|
1118
|
-
await systemManagerConfig.updateConfig({ selectedWorkDir: cwd });
|
|
1119
|
-
await syncSystemManagerManifestWorkDir(cwd);
|
|
1120
|
-
} else {
|
|
1121
|
-
const config = await systemManagerConfig.getConfig();
|
|
1122
|
-
cwd = config.selectedWorkDir;
|
|
1123
|
-
}
|
|
1565
|
+
function shellQuote(value: string): string {
|
|
1566
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
1567
|
+
}
|
|
1124
1568
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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;
|
|
1139
1626
|
}
|
|
1140
|
-
);
|
|
1141
1627
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
try {
|
|
1146
|
-
assertTrustedBrowserOrigin(request);
|
|
1147
|
-
systemManagerPty.write(request.params.ptyId, request.body?.data ?? '');
|
|
1148
|
-
return { ok: true };
|
|
1149
|
-
} catch (err) {
|
|
1150
|
-
return reply.code(404).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1151
|
-
}
|
|
1628
|
+
if (process.platform === 'win32') {
|
|
1629
|
+
await spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', windowsShellLine]);
|
|
1630
|
+
return;
|
|
1152
1631
|
}
|
|
1153
|
-
);
|
|
1154
1632
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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) {
|
|
1158
1648
|
try {
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
request.params.ptyId,
|
|
1162
|
-
request.body?.cols ?? 120,
|
|
1163
|
-
request.body?.rows ?? 34
|
|
1164
|
-
);
|
|
1165
|
-
return { ok: true };
|
|
1649
|
+
await spawnDetached(candidate.file, candidate.args);
|
|
1650
|
+
return;
|
|
1166
1651
|
} catch (err) {
|
|
1167
|
-
|
|
1652
|
+
errors.push(`${candidate.file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1168
1653
|
}
|
|
1169
1654
|
}
|
|
1170
|
-
);
|
|
1171
|
-
|
|
1172
|
-
app.delete<{ Params: { ptyId: string } }>('/api/terminal/:ptyId', async (request, reply) => {
|
|
1173
|
-
try {
|
|
1174
|
-
assertTrustedBrowserOrigin(request);
|
|
1175
|
-
await systemManagerPty.kill(request.params.ptyId);
|
|
1176
|
-
return { ok: true };
|
|
1177
|
-
} catch (err) {
|
|
1178
|
-
return reply.code(403).send({ error: err instanceof Error ? err.message : String(err) });
|
|
1179
|
-
}
|
|
1180
|
-
});
|
|
1655
|
+
throw new Error(`No system terminal launcher succeeded. ${errors.join('; ')}`);
|
|
1656
|
+
}
|
|
1181
1657
|
|
|
1182
|
-
// POST /api/terminal/open-external — open command in system
|
|
1658
|
+
// POST /api/terminal/open-external — open command in an external/system terminal
|
|
1183
1659
|
app.post<{ Body: { command: string; args?: string[]; cwd?: string } }>(
|
|
1184
1660
|
'/api/terminal/open-external',
|
|
1185
1661
|
async (request, reply) => {
|
|
1186
1662
|
try {
|
|
1663
|
+
assertTrustedBrowserOrigin(request);
|
|
1187
1664
|
const { command, args = [], cwd } = request.body ?? {};
|
|
1188
1665
|
if (!command) return reply.code(400).send({ error: 'command is required' });
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
const
|
|
1194
|
-
|
|
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);
|
|
1195
1674
|
return { ok: true };
|
|
1196
1675
|
} catch (err) {
|
|
1197
|
-
|
|
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 });
|
|
1198
1680
|
}
|
|
1199
1681
|
}
|
|
1200
1682
|
);
|
|
1201
1683
|
|
|
1684
|
+
// Worker Society REST 路由(/api/society/*)—— worker 自治社会的 HTTP 接口(workers/needs/social/feed)。
|
|
1685
|
+
registerSocietyRoutes(app, workerSociety);
|
|
1686
|
+
|
|
1202
1687
|
// GET /api/teams → Hermit 本地团队优先,裸 cc-connect project 作为历史兼容显示;过滤飞书/系统项目
|
|
1203
1688
|
app.get('/api/teams', async () => {
|
|
1204
1689
|
try {
|
|
@@ -1224,21 +1709,14 @@ app.get('/api/teams', async () => {
|
|
|
1224
1709
|
.map(async (meta) => {
|
|
1225
1710
|
const bindProject = meta.bindProject || meta.slug;
|
|
1226
1711
|
const project = projectByName.get(bindProject);
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const detail = await cc.getProject(bindProject);
|
|
1232
|
-
if (typeof detail.work_dir === 'string' && detail.work_dir.trim()) {
|
|
1233
|
-
workDir = detail.work_dir.trim();
|
|
1234
|
-
}
|
|
1235
|
-
} catch {
|
|
1236
|
-
// ignore detail read failure, keep manifest/default path
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
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();
|
|
1239
1716
|
const harness = toCcAgentType(project?.agent_type || meta.harness);
|
|
1240
1717
|
const color = meta.color || 'blue';
|
|
1241
1718
|
const displayName = meta.displayName || meta.slug;
|
|
1719
|
+
const usageStats = workDir ? getProjectStatsSnapshot(workDir) : null;
|
|
1242
1720
|
|
|
1243
1721
|
return {
|
|
1244
1722
|
teamName: meta.slug,
|
|
@@ -1249,16 +1727,27 @@ app.get('/api/teams', async () => {
|
|
|
1249
1727
|
members: [{ name: displayName, role: 'agent', agentId: harness, color }],
|
|
1250
1728
|
taskCount: 0,
|
|
1251
1729
|
lastActivity: null,
|
|
1252
|
-
isAlive:
|
|
1730
|
+
isAlive: false,
|
|
1253
1731
|
harness,
|
|
1254
1732
|
bindProject,
|
|
1255
1733
|
workDir,
|
|
1256
|
-
projectPath:
|
|
1734
|
+
projectPath: projectPath || undefined,
|
|
1257
1735
|
sessionsCount: project?.sessions_count ?? 0,
|
|
1258
1736
|
heartbeatEnabled: project?.heartbeat_enabled ?? false,
|
|
1259
1737
|
pendingDelete: meta.pendingDelete === true,
|
|
1260
1738
|
restartRequired: meta.restartRequired === true,
|
|
1261
|
-
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,
|
|
1262
1751
|
};
|
|
1263
1752
|
})
|
|
1264
1753
|
);
|
|
@@ -1453,7 +1942,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
|
|
|
1453
1942
|
disabledCommands: resolvedDisabledCommands,
|
|
1454
1943
|
platformAllowFrom: resolvedPlatformAllowFrom,
|
|
1455
1944
|
platformAllowChat: resolvedPlatformAllowChat,
|
|
1456
|
-
projectPath: p.work_dir
|
|
1945
|
+
projectPath: workDir || p.work_dir,
|
|
1457
1946
|
members: [{ name: displayName, role: 'lead' }],
|
|
1458
1947
|
},
|
|
1459
1948
|
tasks: teamTasks,
|
|
@@ -1511,6 +2000,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
|
|
|
1511
2000
|
managedSources,
|
|
1512
2001
|
disabledCommands,
|
|
1513
2002
|
platformAllowFrom,
|
|
2003
|
+
platformAllowChat,
|
|
1514
2004
|
projectPath: workDir,
|
|
1515
2005
|
members: [{ name: displayName, role: 'lead' }],
|
|
1516
2006
|
},
|
|
@@ -1547,6 +2037,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
|
|
|
1547
2037
|
reply_footer: replyFooter,
|
|
1548
2038
|
inject_sender: injectSender,
|
|
1549
2039
|
platform_allow_from: platformAllowFrom,
|
|
2040
|
+
platform_allow_chat: platformAllowChat,
|
|
1550
2041
|
},
|
|
1551
2042
|
activeSessions: [],
|
|
1552
2043
|
};
|
|
@@ -1572,21 +2063,39 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
|
|
|
1572
2063
|
async (request, reply) => {
|
|
1573
2064
|
const teamName = request.params.name;
|
|
1574
2065
|
if (isReservedSystemTeamName(teamName)) {
|
|
1575
|
-
return reply.code(403).send({ error: '
|
|
2066
|
+
return reply.code(403).send({ error: 'Helm Loop 不可删除' });
|
|
1576
2067
|
}
|
|
1577
2068
|
try {
|
|
1578
2069
|
let restartRequired = false;
|
|
2070
|
+
let ccProjectName = teamName;
|
|
2071
|
+
let localTeamName = teamName;
|
|
1579
2072
|
try {
|
|
1580
|
-
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);
|
|
1581
2084
|
restartRequired = result.restart_required === true;
|
|
1582
2085
|
} catch (err) {
|
|
1583
|
-
request.log.warn(
|
|
2086
|
+
request.log.warn(
|
|
2087
|
+
{ err, teamName, ccProjectName },
|
|
2088
|
+
'delete cc-connect project failed or project missing'
|
|
2089
|
+
);
|
|
1584
2090
|
}
|
|
1585
2091
|
|
|
1586
2092
|
try {
|
|
1587
|
-
await svc.deleteTeam(
|
|
2093
|
+
await svc.deleteTeam(localTeamName, { deleteFiles: request.query.deleteFiles === 'true' });
|
|
1588
2094
|
} catch (err) {
|
|
1589
|
-
request.log.warn(
|
|
2095
|
+
request.log.warn(
|
|
2096
|
+
{ err, teamName, localTeamName },
|
|
2097
|
+
'delete local team metadata failed or already missing'
|
|
2098
|
+
);
|
|
1590
2099
|
}
|
|
1591
2100
|
|
|
1592
2101
|
return { ok: true, restartRequired };
|
|
@@ -1599,7 +2108,7 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
|
|
|
1599
2108
|
// ===========================================================================
|
|
1600
2109
|
// Tasks — 存储在 ~/.hermit/teams/:name/tasks/board.json
|
|
1601
2110
|
// 双向映射:TeamTask(pending/in_progress/completed) ↔ Task(todo/doing/done)
|
|
1602
|
-
//
|
|
2111
|
+
// 任务创建/指派只更新看板;只有显式点击开始才投递给 runtime/目标团队。
|
|
1603
2112
|
// ===========================================================================
|
|
1604
2113
|
|
|
1605
2114
|
/** TeamTask status → internal Task status */
|
|
@@ -1609,6 +2118,13 @@ function toTaskStatus(s: string): 'todo' | 'doing' | 'done' {
|
|
|
1609
2118
|
return 'todo';
|
|
1610
2119
|
}
|
|
1611
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
|
+
|
|
1612
2128
|
/** internal Task → TeamTask shape (for UI consumption) */
|
|
1613
2129
|
function toTeamTask(task: {
|
|
1614
2130
|
id: string;
|
|
@@ -1651,36 +2167,6 @@ function activeTasks<T extends { result?: string | null }>(tasks: T[]): T[] {
|
|
|
1651
2167
|
return tasks.filter((task) => !isSoftDeletedTask(task));
|
|
1652
2168
|
}
|
|
1653
2169
|
|
|
1654
|
-
function mapCcSessionDetail(detail: {
|
|
1655
|
-
id: string;
|
|
1656
|
-
name: string;
|
|
1657
|
-
session_key: string;
|
|
1658
|
-
agent_session_id?: string;
|
|
1659
|
-
agent_type: string;
|
|
1660
|
-
active: boolean;
|
|
1661
|
-
live: boolean;
|
|
1662
|
-
history_count: number;
|
|
1663
|
-
created_at: string;
|
|
1664
|
-
updated_at: string;
|
|
1665
|
-
platform: string;
|
|
1666
|
-
history: { role: 'user' | 'assistant'; content: string; timestamp: string }[];
|
|
1667
|
-
}) {
|
|
1668
|
-
return {
|
|
1669
|
-
id: detail.id,
|
|
1670
|
-
name: detail.name,
|
|
1671
|
-
sessionKey: detail.session_key,
|
|
1672
|
-
agentSessionId: detail.agent_session_id,
|
|
1673
|
-
agentType: detail.agent_type,
|
|
1674
|
-
active: detail.active,
|
|
1675
|
-
live: detail.live,
|
|
1676
|
-
historyCount: detail.history_count,
|
|
1677
|
-
createdAt: detail.created_at,
|
|
1678
|
-
updatedAt: detail.updated_at,
|
|
1679
|
-
platform: detail.platform,
|
|
1680
|
-
history: detail.history ?? [],
|
|
1681
|
-
};
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
2170
|
app.get<{ Params: { name: string } }>('/api/teams/:name/tasks', async (request) => {
|
|
1685
2171
|
try {
|
|
1686
2172
|
const tasks = activeTasks(await svc.readTasks(request.params.name));
|
|
@@ -1703,33 +2189,34 @@ app.post<{ Params: { name: string }; Body: Record<string, unknown> }>(
|
|
|
1703
2189
|
assignee: (body.owner ?? body.assignee) as string | null | undefined,
|
|
1704
2190
|
status: body.status ? toTaskStatus(body.status as string) : 'todo',
|
|
1705
2191
|
});
|
|
1706
|
-
if (task.assignee) {
|
|
1707
|
-
svc
|
|
1708
|
-
.dispatchTask(request.params.name, task)
|
|
1709
|
-
.catch((err) => request.log.warn({ err }, 'dispatchTask failed'));
|
|
1710
|
-
}
|
|
1711
2192
|
return toTeamTask(task);
|
|
1712
2193
|
}
|
|
1713
2194
|
);
|
|
1714
2195
|
|
|
1715
2196
|
app.patch<{ Params: { name: string; id: string }; Body: Record<string, unknown> }>(
|
|
1716
2197
|
'/api/teams/:name/tasks/:id',
|
|
1717
|
-
async (request) => {
|
|
2198
|
+
async (request, reply) => {
|
|
1718
2199
|
const body = request.body ?? {};
|
|
1719
2200
|
const patch: Record<string, unknown> = {};
|
|
2201
|
+
const nextStatus = body.status !== undefined ? toTaskStatus(body.status as string) : undefined;
|
|
1720
2202
|
if (body.subject !== undefined) patch.title = body.subject;
|
|
1721
2203
|
if (body.title !== undefined) patch.title = body.title;
|
|
1722
2204
|
if (body.description !== undefined) patch.description = body.description;
|
|
1723
|
-
if (
|
|
2205
|
+
if (nextStatus !== undefined) patch.status = nextStatus;
|
|
1724
2206
|
if (body.owner !== undefined) patch.assignee = body.owner;
|
|
1725
2207
|
if (body.assignee !== undefined) patch.assignee = body.assignee;
|
|
1726
2208
|
if (body.result !== undefined) patch.result = body.result;
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
+
});
|
|
1732
2217
|
}
|
|
2218
|
+
|
|
2219
|
+
const task = await svc.patchTask(request.params.name, request.params.id, patch);
|
|
1733
2220
|
return toTeamTask(task);
|
|
1734
2221
|
}
|
|
1735
2222
|
);
|
|
@@ -1738,6 +2225,14 @@ app.delete<{ Params: { name: string; id: string } }>(
|
|
|
1738
2225
|
'/api/teams/:name/tasks/:id',
|
|
1739
2226
|
async (request, reply) => {
|
|
1740
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
|
+
}
|
|
1741
2236
|
await svc.patchTask(request.params.name, request.params.id, {
|
|
1742
2237
|
status: 'done',
|
|
1743
2238
|
result: '__deleted__',
|
|
@@ -1870,7 +2365,279 @@ app.get('/api/harnesses', async () => {
|
|
|
1870
2365
|
}));
|
|
1871
2366
|
} catch {
|
|
1872
2367
|
// cc-connect 不可达时返回完整枚举列表
|
|
1873
|
-
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) });
|
|
1874
2641
|
}
|
|
1875
2642
|
});
|
|
1876
2643
|
|
|
@@ -1888,12 +2655,12 @@ app.post<{ Params: { name: string }; Body: Partial<TeamLaunchRequest> }>(
|
|
|
1888
2655
|
const body = request.body ?? {};
|
|
1889
2656
|
let manifest: TeamManifest | null = null;
|
|
1890
2657
|
try {
|
|
1891
|
-
manifest = await svc.
|
|
2658
|
+
manifest = await svc.readTeamManifestByProject(name);
|
|
1892
2659
|
} catch {
|
|
1893
2660
|
// Team may only exist in cc-connect.
|
|
1894
2661
|
}
|
|
1895
2662
|
const bindProject = manifest?.bindProject ?? name;
|
|
1896
|
-
|
|
2663
|
+
let workDir = body.cwd ?? manifest?.workDir ?? '';
|
|
1897
2664
|
const harness = manifest?.harness ?? 'claudecode';
|
|
1898
2665
|
const platformType = manifest?.platform ?? 'bridge';
|
|
1899
2666
|
const platformOptions = manifest?.platformOptions ?? {};
|
|
@@ -1921,14 +2688,21 @@ app.post<{ Params: { name: string }; Body: Partial<TeamLaunchRequest> }>(
|
|
|
1921
2688
|
platformOptions as Record<string, string>
|
|
1922
2689
|
);
|
|
1923
2690
|
projectExists = true;
|
|
1924
|
-
} catch {
|
|
2691
|
+
} catch {
|
|
2692
|
+
/* CC Connect project creation is best-effort */
|
|
2693
|
+
}
|
|
1925
2694
|
}
|
|
1926
2695
|
// Restart cc-connect to (re-)activate platform connections.
|
|
1927
2696
|
// Covers: newly created project, existing project with disconnected platform,
|
|
1928
2697
|
// Feishu/Lark IM that lost connection after cc-connect restart, etc.
|
|
1929
2698
|
try {
|
|
1930
|
-
await
|
|
1931
|
-
} 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
|
+
}
|
|
1932
2706
|
}
|
|
1933
2707
|
|
|
1934
2708
|
return {
|
|
@@ -1948,7 +2722,9 @@ app.post<{ Params: { name: string } }>('/api/teams/:name/stop', async (request)
|
|
|
1948
2722
|
// Stop = delete project from cc-connect (best-effort, no restart)
|
|
1949
2723
|
try {
|
|
1950
2724
|
await cc.deleteProject(bindProject);
|
|
1951
|
-
} catch {
|
|
2725
|
+
} catch {
|
|
2726
|
+
/* project may not exist in cc-connect */
|
|
2727
|
+
}
|
|
1952
2728
|
// Keep local team metadata intact by not deleting it
|
|
1953
2729
|
// The team will show as offline (isAlive: false) on next data fetch
|
|
1954
2730
|
return { ok: true };
|
|
@@ -1998,16 +2774,27 @@ app.post('/api/setup/feishu/poll', async (request, reply) => {
|
|
|
1998
2774
|
|
|
1999
2775
|
app.post('/api/setup/feishu/save', async (request, reply) => {
|
|
2000
2776
|
try {
|
|
2001
|
-
const
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
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
|
+
}
|
|
2011
2798
|
return result;
|
|
2012
2799
|
} catch (err) {
|
|
2013
2800
|
return reply500(err);
|
|
@@ -2053,16 +2840,27 @@ app.post('/api/setup/weixin/poll', async (request, reply) => {
|
|
|
2053
2840
|
|
|
2054
2841
|
app.post('/api/setup/weixin/save', async (request, reply) => {
|
|
2055
2842
|
try {
|
|
2056
|
-
const
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
+
}
|
|
2066
2864
|
return result;
|
|
2067
2865
|
} catch (err) {
|
|
2068
2866
|
return reply500(err);
|
|
@@ -2075,14 +2873,31 @@ app.post<{
|
|
|
2075
2873
|
Body: { type: string; options?: Record<string, unknown>; work_dir?: string; agent_type?: string };
|
|
2076
2874
|
}>('/api/projects/:name/add-platform', async (request, reply) => {
|
|
2077
2875
|
try {
|
|
2876
|
+
const existingProject = await cc.getProject(request.params.name).catch(() => null);
|
|
2078
2877
|
const result = await cc.createProject(
|
|
2079
2878
|
request.params.name,
|
|
2080
|
-
request.body.agent_type ?? 'claudecode',
|
|
2081
|
-
request.body.work_dir ?? '',
|
|
2879
|
+
request.body.agent_type ?? existingProject?.agent_type ?? 'claudecode',
|
|
2880
|
+
request.body.work_dir ?? existingProject?.work_dir ?? '',
|
|
2082
2881
|
request.body.type,
|
|
2083
2882
|
(request.body.options ?? {}) as Record<string, string>
|
|
2084
2883
|
);
|
|
2085
|
-
|
|
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 } };
|
|
2086
2901
|
} catch (err) {
|
|
2087
2902
|
return reply500(err);
|
|
2088
2903
|
}
|
|
@@ -2263,6 +3078,8 @@ const MCP_TOOLS = [
|
|
|
2263
3078
|
required: ['team_slug', 'dispatch_id', 'feedback'],
|
|
2264
3079
|
},
|
|
2265
3080
|
},
|
|
3081
|
+
// Worker Society —— 去中心化自治社会的 MCP 工具(society_* 命名空间)。
|
|
3082
|
+
...SOCIETY_MCP_TOOLS,
|
|
2266
3083
|
];
|
|
2267
3084
|
|
|
2268
3085
|
/** 执行 MCP tool,返回 content array */
|
|
@@ -2272,12 +3089,28 @@ async function executeMcpTool(
|
|
|
2272
3089
|
): Promise<{ type: string; text: string }[]> {
|
|
2273
3090
|
const text = async (result: unknown) => [{ type: 'text', text: JSON.stringify(result, null, 2) }];
|
|
2274
3091
|
|
|
3092
|
+
// Worker Society 工具(society_*):命中即返回,未命中回退到既有派单/任务工具。
|
|
3093
|
+
const societyResult = await executeSocietyMcpTool(toolName, args, workerSociety);
|
|
3094
|
+
if (societyResult) return societyResult;
|
|
3095
|
+
|
|
2275
3096
|
if (toolName === 'list_tasks') {
|
|
2276
3097
|
const tasks = await svc.readTasks(args.team_slug);
|
|
2277
3098
|
return text(tasks);
|
|
2278
3099
|
}
|
|
2279
3100
|
|
|
2280
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
|
+
}
|
|
2281
3114
|
const task = await svc.patchTask(args.team_slug, args.task_id, { status: 'doing' });
|
|
2282
3115
|
return text(task);
|
|
2283
3116
|
}
|
|
@@ -2413,8 +3246,10 @@ app.post<{
|
|
|
2413
3246
|
// Hermit 主仓 UI 首屏强依赖的几个 stub(占位实现)
|
|
2414
3247
|
// ===========================================================================
|
|
2415
3248
|
|
|
2416
|
-
// hermit getAppVersion
|
|
2417
|
-
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
|
+
);
|
|
2418
3253
|
|
|
2419
3254
|
// GET /api/update/check — 检查是否有新版本
|
|
2420
3255
|
const updateService = new UpdateService();
|
|
@@ -2556,9 +3391,10 @@ function readAppConfig() {
|
|
|
2556
3391
|
return mergeConfigDefaults(DEFAULT_APP_CONFIG, raw);
|
|
2557
3392
|
}
|
|
2558
3393
|
} catch (err) {
|
|
2559
|
-
const msg =
|
|
2560
|
-
|
|
2561
|
-
|
|
3394
|
+
const msg =
|
|
3395
|
+
err instanceof SyntaxError
|
|
3396
|
+
? `${HERMIT_APP_CONFIG_FILE} 格式错误: ${err.message}。将使用默认配置并覆盖修复。`
|
|
3397
|
+
: `读取 ${HERMIT_APP_CONFIG_FILE} 失败`;
|
|
2562
3398
|
app.log.warn({ err }, msg);
|
|
2563
3399
|
// Auto-heal: rewrite with valid defaults
|
|
2564
3400
|
try {
|
|
@@ -2693,13 +3529,14 @@ async function sendHarnessMessageViaBridge(params: {
|
|
|
2693
3529
|
await waitForHarnessBridgeConnected();
|
|
2694
3530
|
|
|
2695
3531
|
const sessionKey = params.sessionKey?.trim() || buildFallbackSessionKey(params.teamName);
|
|
3532
|
+
const projectName = await resolveRouteCcProjectName(params.teamName);
|
|
2696
3533
|
bridge.sendUserMessage({
|
|
2697
3534
|
sessionKey,
|
|
2698
3535
|
userId: 'hermit-user',
|
|
2699
3536
|
userName: 'User',
|
|
2700
3537
|
content: params.text,
|
|
2701
3538
|
msgId: params.msgId,
|
|
2702
|
-
project:
|
|
3539
|
+
project: projectName,
|
|
2703
3540
|
});
|
|
2704
3541
|
return sessionKey;
|
|
2705
3542
|
}
|
|
@@ -2774,7 +3611,10 @@ function mapCronJobToSchedule(
|
|
|
2774
3611
|
let nextRunAt: string | undefined;
|
|
2775
3612
|
if (cronJob.enabled && isNonEmptyString(cronJob.cron_expr)) {
|
|
2776
3613
|
try {
|
|
2777
|
-
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
|
+
});
|
|
2778
3618
|
const next = job.nextRun();
|
|
2779
3619
|
if (next) {
|
|
2780
3620
|
nextRunAt = (next instanceof Date ? next : new Date(next)).toISOString();
|
|
@@ -2899,7 +3739,6 @@ app.post<{ Body: Record<string, unknown> }>('/api/schedules', async (request, re
|
|
|
2899
3739
|
.code(400)
|
|
2900
3740
|
.send({ error: 'teamName、cronExpression、launchConfig.prompt 不能为空' });
|
|
2901
3741
|
}
|
|
2902
|
-
|
|
2903
3742
|
const created = await cc.createCronJob({
|
|
2904
3743
|
project: teamName,
|
|
2905
3744
|
session_key: sessionKey,
|
|
@@ -3237,25 +4076,23 @@ app.post<{ Body: { dirPath?: string } }>('/api/workspace/list', async (request)
|
|
|
3237
4076
|
|
|
3238
4077
|
try {
|
|
3239
4078
|
const entries = readdirSync(target, { withFileTypes: true });
|
|
3240
|
-
const files = entries
|
|
3241
|
-
.
|
|
3242
|
-
.
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
};
|
|
3258
|
-
});
|
|
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
|
+
});
|
|
3259
4096
|
return { path: target, files, hasParent: target !== path.dirname(target) };
|
|
3260
4097
|
} catch {
|
|
3261
4098
|
return { path: target, files: [], hasParent: false, error: `无法访问目录: ${target}` };
|
|
@@ -3725,63 +4562,38 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/sessions', async (reques
|
|
|
3725
4562
|
const workDir = team.workDir || team.bindProject || request.params.name;
|
|
3726
4563
|
const localSessions = await localSessionScanner.scanSummaries(workDir, request.params.name);
|
|
3727
4564
|
|
|
3728
|
-
//
|
|
3729
|
-
|
|
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[] = [];
|
|
3730
4568
|
try {
|
|
3731
4569
|
const bindProject = await resolveRouteCcProjectName(request.params.name);
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
userName: s.user_name ?? null,
|
|
3737
|
-
chatName: s.chat_name ?? null,
|
|
3738
|
-
lastMessage: s.last_message
|
|
3739
|
-
? { role: s.last_message.role, content: s.last_message.content, timestamp: s.last_message.timestamp }
|
|
3740
|
-
: null,
|
|
3741
|
-
});
|
|
3742
|
-
}
|
|
3743
|
-
} catch { /* cc-connect unavailable — local-only data */ }
|
|
4570
|
+
ccSessions = await cc.listSessions(bindProject);
|
|
4571
|
+
} catch {
|
|
4572
|
+
/* cc-connect unavailable — local-only data */
|
|
4573
|
+
}
|
|
3744
4574
|
|
|
3745
|
-
return localSessions.
|
|
3746
|
-
const ccMeta = ccById.get(s.id);
|
|
3747
|
-
return {
|
|
3748
|
-
id: s.id,
|
|
3749
|
-
title: s.title || s.id,
|
|
3750
|
-
projectId: request.params.name,
|
|
3751
|
-
sessionKey: s.id,
|
|
3752
|
-
platform: ccMeta?.platform ?? 'local',
|
|
3753
|
-
userName: ccMeta?.userName ?? null,
|
|
3754
|
-
chatName: ccMeta?.chatName ?? null,
|
|
3755
|
-
active: s.active,
|
|
3756
|
-
live: s.live,
|
|
3757
|
-
historyCount: s.messageCount,
|
|
3758
|
-
createdAt: s.createdAt,
|
|
3759
|
-
updatedAt: s.updatedAt,
|
|
3760
|
-
lastMessage: ccMeta?.lastMessage ?? null,
|
|
3761
|
-
};
|
|
3762
|
-
});
|
|
4575
|
+
return mergeLocalAndCcSessions(localSessions, ccSessions, request.params.name);
|
|
3763
4576
|
} catch {
|
|
3764
4577
|
return [];
|
|
3765
4578
|
}
|
|
3766
4579
|
});
|
|
3767
4580
|
|
|
3768
4581
|
// GET session detail — read local JSONL file for session history with pagination
|
|
3769
|
-
app.get<{
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
);
|
|
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
|
+
});
|
|
3785
4597
|
|
|
3786
4598
|
// DELETE session — 关闭 cc-connect live session,使其从运行中转为历史会话。
|
|
3787
4599
|
app.delete<{ Params: { name: string; sessionId: string } }>(
|
|
@@ -3842,15 +4654,8 @@ app.post<{ Params: { name: string }; Body: { text?: string; message?: string } }
|
|
|
3842
4654
|
try {
|
|
3843
4655
|
const text = request.body?.text ?? request.body?.message ?? '';
|
|
3844
4656
|
if (text) {
|
|
3845
|
-
let targetProject = request.params.name;
|
|
3846
|
-
try {
|
|
3847
|
-
const manifest = await svc.readTeamManifest(request.params.name);
|
|
3848
|
-
targetProject = manifest.bindProject || request.params.name;
|
|
3849
|
-
} catch {
|
|
3850
|
-
// request.params.name may already be a cc-connect project name.
|
|
3851
|
-
}
|
|
3852
4657
|
await sendHarnessMessageViaBridge({
|
|
3853
|
-
teamName:
|
|
4658
|
+
teamName: request.params.name,
|
|
3854
4659
|
text,
|
|
3855
4660
|
});
|
|
3856
4661
|
}
|
|
@@ -3904,8 +4709,16 @@ app.get('/api/teams/tasks', async () => {
|
|
|
3904
4709
|
// 团队任务子操作 — 全部委托给 svc.patchTask
|
|
3905
4710
|
app.post<{ Params: { name: string; id: string } }>(
|
|
3906
4711
|
'/api/teams/:name/tasks/:id/request-review',
|
|
3907
|
-
async (request) => {
|
|
4712
|
+
async (request, reply) => {
|
|
3908
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
|
+
}
|
|
3909
4722
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'done' });
|
|
3910
4723
|
return { ok: true, data: toTeamTask(task) };
|
|
3911
4724
|
} catch {
|
|
@@ -3922,12 +4735,24 @@ app.patch<{ Params: { name: string; id: string }; Body: Record<string, unknown>
|
|
|
3922
4735
|
);
|
|
3923
4736
|
app.patch<{ Params: { name: string; id: string }; Body: { status?: string } }>(
|
|
3924
4737
|
'/api/teams/:name/tasks/:id/status',
|
|
3925
|
-
async (request) => {
|
|
4738
|
+
async (request, reply) => {
|
|
3926
4739
|
try {
|
|
3927
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
|
+
}
|
|
3928
4750
|
const task = await svc.patchTask(request.params.name, request.params.id, {
|
|
3929
|
-
status:
|
|
4751
|
+
status: nextStatus,
|
|
3930
4752
|
});
|
|
4753
|
+
if (task.dispatchMeta && task.status === 'done') {
|
|
4754
|
+
await taskDispatch.onTaskCompleted(request.params.name, request.params.id).catch(() => {});
|
|
4755
|
+
}
|
|
3931
4756
|
return toTeamTask(task);
|
|
3932
4757
|
} catch {
|
|
3933
4758
|
return { ok: true };
|
|
@@ -3942,9 +4767,6 @@ app.patch<{ Params: { name: string; id: string }; Body: { owner?: string } }>(
|
|
|
3942
4767
|
const task = await svc.patchTask(request.params.name, request.params.id, {
|
|
3943
4768
|
assignee: body.owner ?? null,
|
|
3944
4769
|
});
|
|
3945
|
-
if (task.assignee) {
|
|
3946
|
-
svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
3947
|
-
}
|
|
3948
4770
|
return toTeamTask(task);
|
|
3949
4771
|
} catch {
|
|
3950
4772
|
return { ok: true };
|
|
@@ -3970,9 +4792,16 @@ app.post<{ Params: { name: string; id: string } }>(
|
|
|
3970
4792
|
'/api/teams/:name/tasks/:id/start',
|
|
3971
4793
|
async (request) => {
|
|
3972
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
|
+
|
|
3973
4802
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'doing' });
|
|
3974
4803
|
if (task.assignee) {
|
|
3975
|
-
svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
4804
|
+
await svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
3976
4805
|
return { notifiedOwner: true };
|
|
3977
4806
|
}
|
|
3978
4807
|
return { notifiedOwner: false };
|
|
@@ -3985,9 +4814,16 @@ app.post<{ Params: { name: string; id: string } }>(
|
|
|
3985
4814
|
'/api/teams/:name/tasks/:id/start-by-user',
|
|
3986
4815
|
async (request) => {
|
|
3987
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
|
+
|
|
3988
4824
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'doing' });
|
|
3989
4825
|
if (task.assignee) {
|
|
3990
|
-
svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
4826
|
+
await svc.dispatchTask(request.params.name, task).catch(() => {});
|
|
3991
4827
|
return { notifiedOwner: true };
|
|
3992
4828
|
}
|
|
3993
4829
|
return { notifiedOwner: false };
|
|
@@ -4000,6 +4836,14 @@ app.post<{ Params: { name: string; id: string } }>(
|
|
|
4000
4836
|
'/api/teams/:name/tasks/:id/soft-delete',
|
|
4001
4837
|
async (request, reply) => {
|
|
4002
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
|
+
}
|
|
4003
4847
|
await svc.patchTask(request.params.name, request.params.id, {
|
|
4004
4848
|
status: 'done',
|
|
4005
4849
|
result: '__deleted__',
|
|
@@ -4094,18 +4938,17 @@ async function applyTeamConfigUpdate(
|
|
|
4094
4938
|
const providerRefs = Array.isArray(body.providerRefs)
|
|
4095
4939
|
? normalizeStringArray(body.providerRefs)
|
|
4096
4940
|
: undefined;
|
|
4097
|
-
const platformAllowFrom = body.platformAllowFrom
|
|
4098
|
-
|
|
4099
|
-
: undefined;
|
|
4100
|
-
const platformAllowChat = body.platformAllowChat
|
|
4101
|
-
? normalizePlatformAllowFrom(body.platformAllowChat)
|
|
4102
|
-
: undefined;
|
|
4941
|
+
const platformAllowFrom = normalizePlatformAllowUpdate(body.platformAllowFrom);
|
|
4942
|
+
const platformAllowChat = normalizePlatformAllowUpdate(body.platformAllowChat);
|
|
4103
4943
|
|
|
4104
|
-
// 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
|
+
}
|
|
4105
4948
|
if (agentType && agentType !== 'claudecode') {
|
|
4106
4949
|
try {
|
|
4107
|
-
const {
|
|
4108
|
-
|
|
4950
|
+
const { execFileSync } = await import('node:child_process');
|
|
4951
|
+
execFileSync('which', [agentType], { stdio: 'pipe', timeout: 5000 });
|
|
4109
4952
|
} catch {
|
|
4110
4953
|
throw new Error(
|
|
4111
4954
|
`${agentType} CLI 未安装,无法切换到 ${agentType} 模式。请先安装对应的 CLI 工具。`
|
|
@@ -4118,7 +4961,9 @@ async function applyTeamConfigUpdate(
|
|
|
4118
4961
|
if (description) localPatch.description = description;
|
|
4119
4962
|
if (color) localPatch.color = color;
|
|
4120
4963
|
if (agentType) localPatch.harness = agentType;
|
|
4121
|
-
if (workDir)
|
|
4964
|
+
if (workDir) {
|
|
4965
|
+
localPatch.workDir = workDir;
|
|
4966
|
+
}
|
|
4122
4967
|
if (permissionMode) localPatch.permissionMode = permissionMode;
|
|
4123
4968
|
if (language) localPatch.language = language;
|
|
4124
4969
|
if (managedSources) localPatch.managedSources = managedSources;
|
|
@@ -4168,6 +5013,7 @@ async function applyTeamConfigUpdate(
|
|
|
4168
5013
|
} catch {
|
|
4169
5014
|
bindProject = teamName;
|
|
4170
5015
|
}
|
|
5016
|
+
|
|
4171
5017
|
if (Object.keys(ccPatch).length > 0) {
|
|
4172
5018
|
try {
|
|
4173
5019
|
const updateResult = await cc.updateProject(
|
|
@@ -4175,17 +5021,25 @@ async function applyTeamConfigUpdate(
|
|
|
4175
5021
|
ccPatch as Parameters<CcConnectClient['updateProject']>[1]
|
|
4176
5022
|
);
|
|
4177
5023
|
if (updateResult.restart_required) {
|
|
4178
|
-
try {
|
|
5024
|
+
try {
|
|
5025
|
+
await cc.reload();
|
|
5026
|
+
} catch {
|
|
5027
|
+
/* best effort */
|
|
5028
|
+
}
|
|
4179
5029
|
}
|
|
4180
5030
|
} catch (err) {
|
|
4181
|
-
|
|
5031
|
+
if (!isCcProjectNotFoundError(err)) {
|
|
5032
|
+
ccSyncError = err instanceof Error ? err.message : String(err);
|
|
5033
|
+
}
|
|
4182
5034
|
}
|
|
4183
5035
|
}
|
|
4184
5036
|
if (providerRefs !== undefined) {
|
|
4185
5037
|
try {
|
|
4186
5038
|
await cc.setProviderRefs(bindProject, providerRefs);
|
|
4187
5039
|
} catch (err) {
|
|
4188
|
-
|
|
5040
|
+
if (!isCcProjectNotFoundError(err)) {
|
|
5041
|
+
ccSyncError = err instanceof Error ? err.message : String(err);
|
|
5042
|
+
}
|
|
4189
5043
|
}
|
|
4190
5044
|
}
|
|
4191
5045
|
|
|
@@ -4204,6 +5058,7 @@ async function applyTeamConfigUpdate(
|
|
|
4204
5058
|
replyFooter: replyFooter ?? false,
|
|
4205
5059
|
injectSender: injectSender ?? false,
|
|
4206
5060
|
platformAllowFrom: platformAllowFrom ?? {},
|
|
5061
|
+
platformAllowChat: platformAllowChat ?? {},
|
|
4207
5062
|
providerRefs: providerRefs ?? [],
|
|
4208
5063
|
ccSyncError,
|
|
4209
5064
|
};
|
|
@@ -4295,7 +5150,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/config', async (request,
|
|
|
4295
5150
|
return {
|
|
4296
5151
|
name,
|
|
4297
5152
|
color,
|
|
4298
|
-
projectPath: p.work_dir
|
|
5153
|
+
projectPath: p.work_dir || '',
|
|
4299
5154
|
description,
|
|
4300
5155
|
agentType: p.agent_type,
|
|
4301
5156
|
workDir: p.work_dir ?? '',
|
|
@@ -4319,6 +5174,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/config', async (request,
|
|
|
4319
5174
|
reply_footer: resolvedReplyFooter,
|
|
4320
5175
|
inject_sender: resolvedInjectSender,
|
|
4321
5176
|
platform_allow_from: resolvedPlatformAllowFrom,
|
|
5177
|
+
platform_allow_chat: resolvedPlatformAllowChat,
|
|
4322
5178
|
},
|
|
4323
5179
|
};
|
|
4324
5180
|
} catch {
|
|
@@ -4412,6 +5268,7 @@ app.post<{
|
|
|
4412
5268
|
{ deadlineMinutes: 10, needsHumanReview: true }
|
|
4413
5269
|
);
|
|
4414
5270
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
5271
|
+
broadcastSse('team-change', { type: 'task', teamName: targetTeam });
|
|
4415
5272
|
broadcastSse('collab-change', {
|
|
4416
5273
|
dispatchId: result.dispatchId,
|
|
4417
5274
|
status: result.status,
|
|
@@ -4420,14 +5277,15 @@ app.post<{
|
|
|
4420
5277
|
});
|
|
4421
5278
|
return {
|
|
4422
5279
|
ok: result.status !== 'failed',
|
|
4423
|
-
deliveredToInbox:
|
|
5280
|
+
deliveredToInbox: false,
|
|
4424
5281
|
messageId: sourceMsg.id,
|
|
4425
5282
|
dispatchId: result.dispatchId,
|
|
4426
5283
|
status: result.status,
|
|
4427
5284
|
message: result.message,
|
|
4428
5285
|
runtimeDelivery: {
|
|
4429
|
-
attempted:
|
|
4430
|
-
delivered:
|
|
5286
|
+
attempted: false,
|
|
5287
|
+
delivered: false,
|
|
5288
|
+
reason: 'waiting_for_target_start',
|
|
4431
5289
|
},
|
|
4432
5290
|
};
|
|
4433
5291
|
} catch (err) {
|
|
@@ -4445,6 +5303,7 @@ app.post<{
|
|
|
4445
5303
|
// 本地存储用户消息
|
|
4446
5304
|
const userMsg = await svc
|
|
4447
5305
|
.appendMessage(teamName, {
|
|
5306
|
+
id: msgId,
|
|
4448
5307
|
from: 'user',
|
|
4449
5308
|
to: teamName,
|
|
4450
5309
|
role: 'user',
|
|
@@ -4456,16 +5315,37 @@ app.post<{
|
|
|
4456
5315
|
// 广播 SSE 让前端触发消息刷新
|
|
4457
5316
|
broadcastSse('team-change', { type: 'inbox', teamName });
|
|
4458
5317
|
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
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
|
+
}
|
|
4469
5349
|
|
|
4470
5350
|
return {
|
|
4471
5351
|
ok: true,
|
|
@@ -4473,7 +5353,7 @@ app.post<{
|
|
|
4473
5353
|
messageId: userMsg?.id ?? msgId,
|
|
4474
5354
|
runtimeDelivery: {
|
|
4475
5355
|
attempted: true,
|
|
4476
|
-
delivered:
|
|
5356
|
+
delivered: dispatchedDirect,
|
|
4477
5357
|
},
|
|
4478
5358
|
};
|
|
4479
5359
|
});
|
|
@@ -4485,8 +5365,16 @@ app.post<{
|
|
|
4485
5365
|
// requestReview: 前端调用 /tasks/:id/review,服务端原路由是 /tasks/:id/request-review
|
|
4486
5366
|
app.post<{ Params: { name: string; id: string } }>(
|
|
4487
5367
|
'/api/teams/:name/tasks/:id/review',
|
|
4488
|
-
async (request) => {
|
|
5368
|
+
async (request, reply) => {
|
|
4489
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
|
+
}
|
|
4490
5378
|
const task = await svc.patchTask(request.params.name, request.params.id, { status: 'done' });
|
|
4491
5379
|
return { ok: true, data: toTeamTask(task) };
|
|
4492
5380
|
} catch {
|
|
@@ -4606,6 +5494,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4606
5494
|
let inputTokens = 0;
|
|
4607
5495
|
let outputTokens = 0;
|
|
4608
5496
|
let cacheReadTokens = 0;
|
|
5497
|
+
let cacheCreationTokens = 0;
|
|
5498
|
+
let totalTokens = 0;
|
|
4609
5499
|
let messageCount = 0;
|
|
4610
5500
|
let totalDurationMs = 0;
|
|
4611
5501
|
|
|
@@ -4616,6 +5506,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4616
5506
|
inputTokens += s.inputTokens;
|
|
4617
5507
|
outputTokens += s.outputTokens;
|
|
4618
5508
|
cacheReadTokens += s.cacheReadTokens;
|
|
5509
|
+
cacheCreationTokens += s.cacheCreationTokens;
|
|
5510
|
+
totalTokens += s.totalTokens;
|
|
4619
5511
|
messageCount += s.messageCount;
|
|
4620
5512
|
|
|
4621
5513
|
if (s.startTime && (!earliestStart || s.startTime < earliestStart)) {
|
|
@@ -4649,6 +5541,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4649
5541
|
inputTokens,
|
|
4650
5542
|
outputTokens,
|
|
4651
5543
|
cacheReadTokens,
|
|
5544
|
+
cacheCreationTokens,
|
|
5545
|
+
totalTokens,
|
|
4652
5546
|
costUsd: 0,
|
|
4653
5547
|
tasksCompleted,
|
|
4654
5548
|
messageCount,
|
|
@@ -4666,6 +5560,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4666
5560
|
inputTokens: 0,
|
|
4667
5561
|
outputTokens: 0,
|
|
4668
5562
|
cacheReadTokens: 0,
|
|
5563
|
+
cacheCreationTokens: 0,
|
|
5564
|
+
totalTokens: 0,
|
|
4669
5565
|
costUsd: 0,
|
|
4670
5566
|
tasksCompleted: 0,
|
|
4671
5567
|
messageCount: 0,
|
|
@@ -4677,14 +5573,79 @@ app.get<{ Params: { name: string; memberName: string } }>(
|
|
|
4677
5573
|
}
|
|
4678
5574
|
);
|
|
4679
5575
|
|
|
4680
|
-
// tool-approval
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
}
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
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
|
+
);
|
|
4688
5649
|
|
|
4689
5650
|
// validate-cli-args
|
|
4690
5651
|
app.post('/api/teams/validate-cli-args', async () => ({ valid: true, args: [], errors: [] }));
|
|
@@ -4743,6 +5704,110 @@ app.get<{ Querystring: { excludeTeam?: string } }>('/api/cross-team/targets', as
|
|
|
4743
5704
|
}));
|
|
4744
5705
|
});
|
|
4745
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
|
+
|
|
4746
5811
|
app.get<{ Params: { name: string } }>('/api/cross-team/outbox/:name', async (request) => {
|
|
4747
5812
|
const teamSlug = request.params.name;
|
|
4748
5813
|
const tasks = await svc.readTasks(teamSlug);
|
|
@@ -4897,11 +5962,6 @@ app.post<{
|
|
|
4897
5962
|
typeof sessionKey === 'string' && sessionKey.trim().length > 0
|
|
4898
5963
|
? sessionKey.trim()
|
|
4899
5964
|
: buildFallbackSessionKey(fromTeam);
|
|
4900
|
-
const toSessionKey = buildFallbackSessionKey(resolvedToTeam);
|
|
4901
|
-
const sentText = formatCrossTeamText(`${fromTeam}.${sender}`, depth, trimmedText, {
|
|
4902
|
-
conversationId: threadId,
|
|
4903
|
-
replyToConversationId,
|
|
4904
|
-
});
|
|
4905
5965
|
const meta = {
|
|
4906
5966
|
taskRefs,
|
|
4907
5967
|
actionMode,
|
|
@@ -4919,53 +5979,35 @@ app.post<{
|
|
|
4919
5979
|
meta: { ...meta, source: CROSS_TEAM_SENT_SOURCE, sessionKey: fromSessionKey },
|
|
4920
5980
|
});
|
|
4921
5981
|
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
source: CROSS_TEAM_SOURCE,
|
|
4930
|
-
relayOfMessageId: outgoing.id,
|
|
4931
|
-
sessionKey: toSessionKey,
|
|
4932
|
-
},
|
|
4933
|
-
});
|
|
4934
|
-
|
|
4935
|
-
const existingTasks = await svc.readTasks(resolvedToTeam).catch(() => []);
|
|
4936
|
-
const existingTask = existingTasks.find((task) => task.dispatchMeta?.dispatchId === threadId);
|
|
4937
|
-
if (!existingTask) {
|
|
4938
|
-
const now = new Date().toISOString();
|
|
4939
|
-
await svc.createTask(resolvedToTeam, {
|
|
4940
|
-
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) || '跨团队 @ 消息',
|
|
4941
5989
|
description: trimmedText,
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
receivedAt: now,
|
|
4950
|
-
},
|
|
4951
|
-
});
|
|
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 };
|
|
4952
5997
|
}
|
|
4953
5998
|
|
|
4954
5999
|
broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
|
|
4955
|
-
broadcastSse('team-change', { type: 'inbox', teamName: resolvedToTeam });
|
|
4956
6000
|
broadcastSse('team-change', { type: 'task', teamName: resolvedToTeam });
|
|
4957
6001
|
|
|
4958
|
-
void sendHarnessMessageViaBridge({
|
|
4959
|
-
teamName: resolvedToTeam,
|
|
4960
|
-
text: sentText,
|
|
4961
|
-
}).catch((err) => {
|
|
4962
|
-
request.log.warn({ err }, 'cross-team runtime delivery failed after persistence');
|
|
4963
|
-
});
|
|
4964
|
-
|
|
4965
6002
|
return {
|
|
4966
6003
|
messageId: outgoing.id,
|
|
4967
|
-
deliveredToInbox:
|
|
6004
|
+
deliveredToInbox: false,
|
|
4968
6005
|
deduplicated: false,
|
|
6006
|
+
runtimeDelivery: {
|
|
6007
|
+
attempted: false,
|
|
6008
|
+
delivered: false,
|
|
6009
|
+
reason: 'waiting_for_target_start',
|
|
6010
|
+
},
|
|
4969
6011
|
};
|
|
4970
6012
|
}
|
|
4971
6013
|
|
|
@@ -4984,21 +6026,6 @@ app.post<{
|
|
|
4984
6026
|
});
|
|
4985
6027
|
broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
|
|
4986
6028
|
|
|
4987
|
-
// Check collaboration toggle
|
|
4988
|
-
try {
|
|
4989
|
-
const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
|
|
4990
|
-
const raw = await fs.readFile(configPath, 'utf-8');
|
|
4991
|
-
const settings = JSON.parse(raw);
|
|
4992
|
-
if (!settings.taskBus?.collaboration) {
|
|
4993
|
-
return {
|
|
4994
|
-
ok: false,
|
|
4995
|
-
error: 'Distributed collaboration is not enabled. Enable it in Settings → Task Bus.',
|
|
4996
|
-
};
|
|
4997
|
-
}
|
|
4998
|
-
} catch {
|
|
4999
|
-
return { ok: false, error: 'Could not read task bus configuration.' };
|
|
5000
|
-
}
|
|
5001
|
-
|
|
5002
6029
|
const result = await taskDispatch.dispatchTask(
|
|
5003
6030
|
fromTeam ?? 'unknown',
|
|
5004
6031
|
{ subject, description, prompt },
|
|
@@ -5011,15 +6038,7 @@ app.post<{
|
|
|
5011
6038
|
const ok = result.status !== 'failed';
|
|
5012
6039
|
if (ok) {
|
|
5013
6040
|
broadcastSse('team-change', { type: 'inbox', teamName: resolvedToTeam });
|
|
5014
|
-
|
|
5015
|
-
teamName: resolvedToTeam,
|
|
5016
|
-
text: `[跨团队任务] ${fromTeam} 派发了任务:${subject}${description ? `\n\n${description}` : ''}`,
|
|
5017
|
-
}).catch((err) => {
|
|
5018
|
-
request.log.warn(
|
|
5019
|
-
{ err, fromTeam, resolvedToTeam },
|
|
5020
|
-
'cross-team task runtime delivery failed'
|
|
5021
|
-
);
|
|
5022
|
-
});
|
|
6041
|
+
broadcastSse('team-change', { type: 'task', teamName: resolvedToTeam });
|
|
5023
6042
|
}
|
|
5024
6043
|
return {
|
|
5025
6044
|
ok,
|
|
@@ -5138,18 +6157,171 @@ app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
|
|
|
5138
6157
|
return { ok: true, connected: false, message: 'Task bus disabled' };
|
|
5139
6158
|
});
|
|
5140
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
|
+
|
|
5141
6321
|
// POST /api/telemetry/scan → trigger manual scan
|
|
5142
6322
|
app.post('/api/telemetry/scan', async (request, reply) => {
|
|
5143
6323
|
try {
|
|
5144
|
-
const
|
|
5145
|
-
let settings: Record<string, unknown> = {};
|
|
5146
|
-
try {
|
|
5147
|
-
const raw = await fs.readFile(configPath, 'utf-8');
|
|
5148
|
-
settings = JSON.parse(raw);
|
|
5149
|
-
} catch {
|
|
5150
|
-
// no settings
|
|
5151
|
-
}
|
|
5152
|
-
const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
|
|
6324
|
+
const taskBus = await readTaskBusSettings();
|
|
5153
6325
|
if (!taskBus.telemetry?.enabled) {
|
|
5154
6326
|
return reply.code(400).send({ error: 'Telemetry is not enabled' });
|
|
5155
6327
|
}
|
|
@@ -5157,7 +6329,7 @@ app.post('/api/telemetry/scan', async (request, reply) => {
|
|
|
5157
6329
|
if (!result) {
|
|
5158
6330
|
return reply.code(503).send({ error: 'Telemetry scan failed' });
|
|
5159
6331
|
}
|
|
5160
|
-
return {
|
|
6332
|
+
return await enrichTelemetryProjectNames({
|
|
5161
6333
|
ok: true,
|
|
5162
6334
|
connected: taskBus.telemetry.uploadEnabled === true,
|
|
5163
6335
|
lastScan: new Date().toISOString(),
|
|
@@ -5167,16 +6339,35 @@ app.post('/api/telemetry/scan', async (request, reply) => {
|
|
|
5167
6339
|
tokensOut: result.aggregate.tokens.output,
|
|
5168
6340
|
cacheRead: result.aggregate.tokens.cacheRead,
|
|
5169
6341
|
cacheCreation: result.aggregate.tokens.cacheCreation,
|
|
6342
|
+
totalTokens: result.aggregate.tokens.total,
|
|
5170
6343
|
activeDays: result.aggregate.activeDays,
|
|
5171
6344
|
hourly: result.aggregate.hourly,
|
|
5172
6345
|
projects: result.aggregate.projects,
|
|
5173
6346
|
workSecondsByDay: result.aggregate.workSecondsByDay,
|
|
5174
|
-
};
|
|
6347
|
+
});
|
|
5175
6348
|
} catch (err) {
|
|
5176
6349
|
return reply.code(500).send({ error: String(err) });
|
|
5177
6350
|
}
|
|
5178
6351
|
});
|
|
5179
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
|
+
|
|
5180
6371
|
// GET /api/telemetry/conversations → local Feishu/Lark conversation telemetry
|
|
5181
6372
|
app.get<{
|
|
5182
6373
|
Querystring: {
|
|
@@ -5274,48 +6465,12 @@ app.get<{
|
|
|
5274
6465
|
// GET /api/telemetry/status → current telemetry status (full stats)
|
|
5275
6466
|
app.get('/api/telemetry/status', async (request, reply) => {
|
|
5276
6467
|
try {
|
|
5277
|
-
const
|
|
5278
|
-
let settings: Record<string, unknown> = {};
|
|
5279
|
-
try {
|
|
5280
|
-
const raw = await fs.readFile(configPath, 'utf-8');
|
|
5281
|
-
settings = JSON.parse(raw);
|
|
5282
|
-
} catch {
|
|
5283
|
-
// no settings
|
|
5284
|
-
}
|
|
5285
|
-
const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
|
|
6468
|
+
const taskBus = await readTaskBusSettings();
|
|
5286
6469
|
const redisCfg = taskBus.enabled ? taskBus.redis : undefined;
|
|
5287
6470
|
const status = await getTelemetryStatus(redisCfg);
|
|
5288
|
-
return (
|
|
5289
|
-
status ?? {
|
|
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
|
-
}
|
|
5303
|
-
);
|
|
6471
|
+
return await enrichTelemetryProjectNames(status ?? telemetryEmptyStatus());
|
|
5304
6472
|
} catch {
|
|
5305
|
-
return
|
|
5306
|
-
connected: false,
|
|
5307
|
-
lastScan: null,
|
|
5308
|
-
sessions: 0,
|
|
5309
|
-
messages: 0,
|
|
5310
|
-
tokensIn: 0,
|
|
5311
|
-
tokensOut: 0,
|
|
5312
|
-
cacheRead: 0,
|
|
5313
|
-
cacheCreation: 0,
|
|
5314
|
-
activeDays: 0,
|
|
5315
|
-
hourly: [],
|
|
5316
|
-
projects: [],
|
|
5317
|
-
workSecondsByDay: {},
|
|
5318
|
-
};
|
|
6473
|
+
return telemetryEmptyStatus();
|
|
5319
6474
|
}
|
|
5320
6475
|
});
|
|
5321
6476
|
|
|
@@ -5476,6 +6631,22 @@ app.post('/api/extensions/mcp/library/import', async (request) => {
|
|
|
5476
6631
|
return ext.mcpLibraryImport((request.body ?? {}) as any);
|
|
5477
6632
|
});
|
|
5478
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
|
+
|
|
5479
6650
|
app.get('/api/extensions/skills', async (request) => {
|
|
5480
6651
|
const projectPath = (request.query as Record<string, string>).projectPath;
|
|
5481
6652
|
const result = await ext.skillsList(projectPath);
|
|
@@ -5688,6 +6859,7 @@ try {
|
|
|
5688
6859
|
// graceful shutdown
|
|
5689
6860
|
const shutdown = async () => {
|
|
5690
6861
|
try {
|
|
6862
|
+
directCliManager.shutdown();
|
|
5691
6863
|
bridge.dispose?.();
|
|
5692
6864
|
await app.close();
|
|
5693
6865
|
process.exit(0);
|
|
@@ -5697,3 +6869,6 @@ const shutdown = async () => {
|
|
|
5697
6869
|
};
|
|
5698
6870
|
process.on('SIGINT', shutdown);
|
|
5699
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());
|