@yancyyu/openhermit 1.6.41 → 1.6.43

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