@yancyyu/openhermit 1.6.42 → 1.6.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +98 -89
  2. package/bin/hermit.mjs +96 -0
  3. package/dist-renderer/assets/{ProjectEditorOverlay-DlFQ6mai.js → ProjectEditorOverlay-C98qSs7-.js} +1 -1
  4. package/dist-renderer/assets/{TeamGraphOverlay-D2TPMPGR.js → TeamGraphOverlay-CsBbZwcL.js} +1 -1
  5. package/dist-renderer/assets/{_basePickBy-Cmd0RHLQ.js → _basePickBy-ZOyLWjMK.js} +1 -1
  6. package/dist-renderer/assets/{_baseUniq-BI_iy8ea.js → _baseUniq-DBb726rt.js} +1 -1
  7. package/dist-renderer/assets/{arc-NzW2mjTP.js → arc-CdiTaR_R.js} +1 -1
  8. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-Bzq85AYv.js → architectureDiagram-VXUJARFQ-Cz3sc5TH.js} +1 -1
  9. package/dist-renderer/assets/{blockDiagram-VD42YOAC-D1PvYS-b.js → blockDiagram-VD42YOAC-DE4c-KJ3.js} +1 -1
  10. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-D49RKzPC.js → c4Diagram-YG6GDRKO-CmTMDTrV.js} +1 -1
  11. package/dist-renderer/assets/channel-KTpqi9eT.js +1 -0
  12. package/dist-renderer/assets/{chunk-4BX2VUAB-fmI_MQmQ.js → chunk-4BX2VUAB-rhHy3tFl.js} +1 -1
  13. package/dist-renderer/assets/{chunk-55IACEB6-Xsv9RCXZ.js → chunk-55IACEB6-fLZBzuo_.js} +1 -1
  14. package/dist-renderer/assets/{chunk-B4BG7PRW-BE1KO8Um.js → chunk-B4BG7PRW-DOzxQhim.js} +1 -1
  15. package/dist-renderer/assets/{chunk-DI55MBZ5-tqJ7Mv7f.js → chunk-DI55MBZ5-COQCcXC5.js} +1 -1
  16. package/dist-renderer/assets/{chunk-FMBD7UC4-DMD45MVJ.js → chunk-FMBD7UC4-IKU9U_Y4.js} +1 -1
  17. package/dist-renderer/assets/{chunk-QN33PNHL-DOhGrz-q.js → chunk-QN33PNHL-D6WV154X.js} +1 -1
  18. package/dist-renderer/assets/{chunk-QZHKN3VN-D8yDgJdD.js → chunk-QZHKN3VN-D90_2DQp.js} +1 -1
  19. package/dist-renderer/assets/{chunk-TZMSLE5B-BcsEDu7A.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-DlSqGHMX.js → cose-bilkent-S5V4N54A-6WiK6U2P.js} +1 -1
  24. package/dist-renderer/assets/{dagre-6UL2VRFP-BTT9tSAx.js → dagre-6UL2VRFP-DF4MMuTn.js} +1 -1
  25. package/dist-renderer/assets/{diagram-PSM6KHXK-Du-U-mK2.js → diagram-PSM6KHXK-CcF1eZ7E.js} +1 -1
  26. package/dist-renderer/assets/{diagram-QEK2KX5R-jFdHeKas.js → diagram-QEK2KX5R-DYlOVPQB.js} +1 -1
  27. package/dist-renderer/assets/{diagram-S2PKOQOG-DKLNK2bu.js → diagram-S2PKOQOG-BHXWsZOP.js} +1 -1
  28. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-CZxHgIIo.js → erDiagram-Q2GNP2WA-GjmuBx8d.js} +1 -1
  29. package/dist-renderer/assets/{flowDiagram-NV44I4VS-v4XStCD0.js → flowDiagram-NV44I4VS-BuS7YVHk.js} +1 -1
  30. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-DJjD_BEL.js → ganttDiagram-JELNMOA3-3Teu5tAa.js} +1 -1
  31. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-BNy-jr03.js → gitGraphDiagram-V2S2FVAM-BiLdCYu5.js} +1 -1
  32. package/dist-renderer/assets/{graph-DDTrn6je.js → graph-CDP_R8ct.js} +1 -1
  33. package/dist-renderer/assets/{index-BBp78BAu.js → index-BSZdT-g-.js} +1 -1
  34. package/dist-renderer/assets/{index-eotrJaYy.js → index-BhWvMqsz.js} +1 -1
  35. package/dist-renderer/assets/{index-D8_B-cfs.js → index-C2_AupSj.js} +1 -1
  36. package/dist-renderer/assets/{index-BQrwHZ-k.js → index-C5ujiwAR.js} +580 -588
  37. package/dist-renderer/assets/index-CIS2CTK9.css +1 -0
  38. package/dist-renderer/assets/{index-CRKQSG9S.js → index-CVNjLwkq.js} +1 -1
  39. package/dist-renderer/assets/{index-DR6Wz52b.js → index-CwG3se0q.js} +1 -1
  40. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DqnOsuza.js → infoDiagram-HS3SLOUP-DLHUFo72.js} +1 -1
  41. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-DTobaO1d.js → journeyDiagram-XKPGCS4Q-BE07RpJD.js} +1 -1
  42. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-HbwVOvWc.js → kanban-definition-3W4ZIXB7-DDHZy4NB.js} +1 -1
  43. package/dist-renderer/assets/{layout--VYmTcw2.js → layout-5nA5wUxO.js} +1 -1
  44. package/dist-renderer/assets/{linear-BsJh89Mr.js → linear-BtF1i2qN.js} +1 -1
  45. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-BZqUZePd.js → mindmap-definition-VGOIOE7T-Z1Ui9Sqy.js} +1 -1
  46. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-B1q_nH6P.js → pieDiagram-ADFJNKIX-LCjxckWv.js} +1 -1
  47. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-UD8QhSEu.js → quadrantDiagram-AYHSOK5B-BOwKjSco.js} +1 -1
  48. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-BA_i7Nw8.js → requirementDiagram-UZGBJVZJ-pChP8Znd.js} +1 -1
  49. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-CMTnX-2d.js → sankeyDiagram-TZEHDZUN-DifZ2qpo.js} +1 -1
  50. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BQXDB615.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-BAsPXy6X.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-BdasmVkC.js → timeline-definition-IT6M3QCI-CPgokIo8.js} +1 -1
  55. package/dist-renderer/assets/{treemap-GDKQZRPO-BkKQqIui.js → treemap-GDKQZRPO-DAVqSR9L.js} +1 -1
  56. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-EAlPHOdx.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 +1709 -534
  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 +679 -74
  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 +115 -37
  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 +143 -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 +156 -83
  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 +41 -5
  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-Ch7JrfUu.js +0 -1
  274. package/dist-renderer/assets/classDiagram-2ON5EDUG-z9I4AnFy.js +0 -1
  275. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-z9I4AnFy.js +0 -1
  276. package/dist-renderer/assets/clone-Dfi1Jx6l.js +0 -1
  277. package/dist-renderer/assets/index-iyjkpSus.css +0 -32
  278. package/dist-renderer/assets/stateDiagram-v2-4FDKWEC3-DTUIBfce.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,28 +44,53 @@ import staticPlugin from '@fastify/static';
44
44
  import { Cron } from 'croner';
45
45
  import Fastify from 'fastify';
46
46
 
47
- import {
48
- 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';
62
- import { seedBuiltinWorkflows, ensureGlobalWorkflows } from './services/system-manager/BuiltinWorkflowSeeder';
75
+ import {
76
+ ensureGlobalWorkflows,
77
+ seedBuiltinWorkflows,
78
+ } from './services/system-manager/BuiltinWorkflowSeeder';
63
79
  import {
64
80
  SYSTEM_MANAGER_BIND_PROJECT,
65
81
  SYSTEM_MANAGER_DISPLAY_NAME,
66
82
  SYSTEM_MANAGER_TEAM_NAME,
67
83
  } from '@shared/types/team';
68
- import type { 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';
69
94
  import type { TeamManifest } from './services/teams-mvp/TeamWorkspaceService';
70
95
  import { UpdateService } from './services/UpdateService';
71
96
  import {
@@ -79,6 +104,18 @@ import {
79
104
  shouldIncludeContent,
80
105
  } from './services/session-intelligence/ConversationTelemetryService';
81
106
  import { LocalSessionScanner } from './services/session-intelligence/LocalSessionScanner';
107
+ import { mergeLocalAndCcSessions } from './services/session-intelligence/teamSessionListMapper';
108
+ import type { CcSession } from '@shared/types/api';
109
+ import { discoverableTeamToWorker, type DiscoverableWorker } from '@shared/types/worker';
110
+ import { LoopAssetsScannerService } from './services/loop-assets/LoopAssetsScannerService';
111
+ import {
112
+ scanProjectStats,
113
+ type ProjectUsageStats,
114
+ } from './services/session-intelligence/SessionUsageParser';
115
+ import {
116
+ isExternalPlatformSessionKey,
117
+ resolveExternalPlatformSessionTeamSlug,
118
+ } from './utils/externalPlatformSessionRouting';
82
119
 
83
120
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
84
121
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'));
@@ -103,7 +140,7 @@ const CC_AGENT_TYPES: readonly CcAgentType[] = [
103
140
  'tmux',
104
141
  ];
105
142
  const SYSTEM_MANAGER_DESCRIPTION =
106
- '项目级 Claude Code 控制台,负责插件、MCP、Env、数字员工和统计数据的托管管理。';
143
+ '项目级 Claude Code Helm Loop,负责插件、MCP、Env、数字员工和统计数据的托管管理。';
107
144
 
108
145
  function toCcAgentType(value: string | undefined): CcAgentType {
109
146
  return CC_AGENT_TYPES.includes(value as CcAgentType) ? (value as CcAgentType) : 'claudecode';
@@ -189,9 +226,10 @@ function loadConfig(): HermitConfig {
189
226
  merged = { ...defaults, ...raw };
190
227
  }
191
228
  } catch (err) {
192
- const msg = err instanceof SyntaxError
193
- ? `${HERMIT_CONFIG_FILE} 格式错误: ${err.message}。将使用默认配置并覆盖修复。`
194
- : `读取 ${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)}`;
195
233
  console.warn(`[Hermit] ${msg}`);
196
234
  // Auto-heal: rewrite the config file with valid defaults + any readable env overrides
197
235
  mkdirSync(HERMIT_HOME, { recursive: true });
@@ -230,7 +268,9 @@ function writeHermitConfigRaw(content: string): HermitConfig {
230
268
  parsed = JSON.parse(content);
231
269
  } catch (err) {
232
270
  if (err instanceof SyntaxError) {
233
- throw new Error(`配置文件 JSON 格式错误: ${err.message}。请检查是否有尾逗号、单引号或注释等非法 JSON 语法。`);
271
+ throw new Error(
272
+ `配置文件 JSON 格式错误: ${err.message}。请检查是否有尾逗号、单引号或注释等非法 JSON 语法。`
273
+ );
234
274
  }
235
275
  throw err;
236
276
  }
@@ -255,31 +295,21 @@ const bridge = new CcConnectBridge({
255
295
  bridgeUrl: runtimeConfig.ccBridgeUrl,
256
296
  bridgeToken: runtimeConfig.ccBridgeToken || runtimeConfig.ccToken,
257
297
  });
258
- const svc = new TeamProvisioningService(cc, bridge);
298
+ const svc = new TeamProvisioningService(cc, bridge, undefined, {
299
+ restartCcConnect: restartCcConnectAndReconnectBridge,
300
+ });
259
301
  const systemManagerConfig = new SystemManagerConfigService(REPO_ROOT);
260
- const systemManagerPty = new SystemManagerPtyService();
261
302
  const workflowPromptService = new WorkflowPromptService();
262
303
 
263
- systemManagerPty.on('data', (event) => broadcastSse('terminal:data', event));
264
- systemManagerPty.on('exit', (event) => broadcastSse('terminal:exit', event));
265
-
266
304
  async function getSystemManagerWorkDir(): Promise<string> {
267
- try {
268
- return (await systemManagerConfig.getConfig()).selectedWorkDir;
269
- } catch {
270
- return REPO_ROOT;
271
- }
272
- }
273
-
274
- async function syncSystemManagerManifestWorkDir(workDir: string): Promise<void> {
275
- try {
276
- const manifest = await svc.readTeamManifest(SYSTEM_MANAGER_TEAM_NAME);
277
- if (manifest.workDir !== workDir) {
278
- await svc.updateTeam(SYSTEM_MANAGER_TEAM_NAME, { workDir });
279
- }
280
- } catch {
281
- // The console team may not exist yet; ensureSystemManager() will create it later.
282
- }
305
+ // Canonical, isolated Helm Loop runtime path. Dedicated (never shared with
306
+ // another team/project) so the admin agent can bootstrap its own CLAUDE.md
307
+ // here without colliding with project work. The manifest workDir is synced to
308
+ // this path by ensureSystemManagerUncached; selectedWorkDir only drives
309
+ // workflow-command discovery, not the runtime location.
310
+ const dir = adminWorkDir();
311
+ await fs.mkdir(dir, { recursive: true }).catch(() => undefined);
312
+ return dir;
283
313
  }
284
314
 
285
315
  let systemManagerEnsurePromise: Promise<SystemManagerSummary> | null = null;
@@ -349,52 +379,142 @@ async function ensureSystemManager(): Promise<SystemManagerSummary> {
349
379
  return systemManagerEnsurePromise;
350
380
  }
351
381
 
382
+ /**
383
+ * Helm Loop bootstrap wrapper. On first open, fetch the ops guide and feed it to
384
+ * the admin lead session as the first turn so the agent seeds its own CLAUDE.md.
385
+ * Idempotent + failure-retrying (see AdminLoopInitializer). The bootstrap user
386
+ * message is also appended to the team inbox so it is visible in the console.
387
+ * Invoked fire-and-forget from the ensure endpoint — never blocks open.
388
+ */
389
+ async function ensureAdminLoopInitialized(): Promise<void> {
390
+ const sessionKey = `${SYSTEM_MANAGER_TEAM_NAME}:lead`;
391
+ await runAdminLoopInit({
392
+ getConfig: () => systemManagerConfig.getConfig(),
393
+ updateConfig: (patch) => systemManagerConfig.updateConfig(patch),
394
+ fetchGuide: () => httpsGetFollowRedirects(HERMIT_OPS_GUIDE_URL),
395
+ log: (message) => app.log.warn({ sessionKey }, message),
396
+ dispatch: async ({ text, messageId }) => {
397
+ const workDir = await getSystemManagerWorkDir();
398
+ await svc
399
+ .appendMessage(SYSTEM_MANAGER_TEAM_NAME, {
400
+ from: 'user',
401
+ to: SYSTEM_MANAGER_TEAM_NAME,
402
+ role: 'user',
403
+ content: text,
404
+ meta: { sessionKey, source: 'admin-init' },
405
+ })
406
+ .catch((err) =>
407
+ app.log.warn({ err, sessionKey }, 'helm loop init: append user message failed')
408
+ );
409
+ await dispatchDirectCliMessage({
410
+ teamName: SYSTEM_MANAGER_TEAM_NAME,
411
+ sessionKey,
412
+ workDir,
413
+ from: SYSTEM_MANAGER_TEAM_NAME,
414
+ to: 'user',
415
+ text,
416
+ messageId,
417
+ });
418
+ broadcastSse('team-change', { type: 'inbox', teamName: SYSTEM_MANAGER_TEAM_NAME });
419
+ },
420
+ });
421
+ }
422
+
352
423
  const conversationTelemetry = new ConversationTelemetryService({
353
424
  cc,
354
425
  listTeams: () => svc.listTeams(),
355
426
  readTeamManifest: (teamName) => svc.readTeamManifest(teamName),
356
427
  });
357
428
  const localSessionScanner = new LocalSessionScanner();
358
-
359
- async function computeTeamStats(
360
- workDir: string
361
- ): Promise<{ sessions: number; messages: number; tokens: number; durationMs: number } | undefined> {
362
- if (!workDir) return undefined;
363
- try {
364
- const sessions = await localSessionScanner.scanSummaries(workDir, '');
365
- if (sessions.length === 0) return undefined;
366
- let tokens = 0;
367
- let messages = 0;
368
- let earliest: string | null = null;
369
- let latest: string | null = null;
370
- for (const s of sessions) {
371
- tokens += s.inputTokens + s.outputTokens;
372
- messages += s.messageCount;
373
- if (s.startTime && (!earliest || s.startTime < earliest)) earliest = s.startTime;
374
- if (s.endTime && (!latest || s.endTime > latest)) latest = s.endTime;
375
- }
376
- let durationMs = 0;
377
- if (earliest && latest) {
378
- durationMs = Date.parse(latest) - Date.parse(earliest);
379
- if (durationMs < 0) durationMs = 0;
380
- }
381
- return { sessions: sessions.length, messages, tokens, durationMs };
382
- } catch {
383
- return undefined;
429
+ const loopAssetsScanner = new LoopAssetsScannerService();
430
+ const TEAM_STATS_CACHE_TTL_MS = 30_000;
431
+ const teamStatsCache = new Map<
432
+ string,
433
+ {
434
+ expiresAt: number;
435
+ value: ProjectUsageStats | null;
436
+ promise?: Promise<ProjectUsageStats | null>;
384
437
  }
438
+ >();
439
+
440
+ function getProjectStatsSnapshot(workDir: string): ProjectUsageStats | null {
441
+ const normalizedWorkDir = workDir.trim();
442
+ if (!normalizedWorkDir) return null;
443
+
444
+ const now = Date.now();
445
+ const cached = teamStatsCache.get(normalizedWorkDir);
446
+ if (cached && cached.expiresAt > now) return cached.value;
447
+ if (cached?.promise) return cached.value;
448
+
449
+ const promise = scanProjectStats(normalizedWorkDir)
450
+ .catch((err) => {
451
+ app.log.warn({ err, workDir: normalizedWorkDir }, 'scan project stats failed');
452
+ return null;
453
+ })
454
+ .then((value) => {
455
+ teamStatsCache.set(normalizedWorkDir, {
456
+ expiresAt: Date.now() + TEAM_STATS_CACHE_TTL_MS,
457
+ value,
458
+ });
459
+ return value;
460
+ });
461
+
462
+ teamStatsCache.set(normalizedWorkDir, {
463
+ expiresAt: now + TEAM_STATS_CACHE_TTL_MS,
464
+ value: cached?.value ?? null,
465
+ promise,
466
+ });
467
+ void promise;
468
+ return cached?.value ?? null;
385
469
  }
386
470
 
387
471
  async function resolveRouteCcProjectName(teamName: string): Promise<string> {
388
- return resolveCcProjectName(teamName, (name) => svc.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);
389
498
  }
390
499
 
391
500
  const collabBoard = new CollaborationBoardService();
392
501
  const taskDispatch = new TaskDispatchService(svc['workspace'], collabBoard);
393
502
 
503
+ // Worker Society —— 去中心化 worker 自治社交平台(替代派单的主路径)。
504
+ // 状态持久化到 ~/.hermit/society/(声誉/关系/需求/消息跨重启存活);REST 路由见下方 registerSocietyRoutes。
505
+ // 成员花名册以 hermit 真实数字员工为单一事实源:注入 listDiscoverableWorkers(GET /api/workers 同款),
506
+ // 社会层身份即真实团队;能力/声誉/并发由 ~/.hermit/society/profiles.json overlay 叠加(MergingProfileStore)。
507
+ const workerSociety = createWorkerSociety(undefined, {
508
+ realWorkersProvider: listDiscoverableWorkers,
509
+ });
510
+
394
511
  // Broadcast collab board changes via SSE
395
512
  taskDispatch.onCollabChange = (dispatchId, status, fromTeam, toTeam) => {
396
513
  broadcastSse('collab-change', { dispatchId, status, fromTeam, toTeam });
397
514
  };
515
+ taskDispatch.onRuntimeStart = async ({ teamName, text }) => {
516
+ await sendHarnessMessageViaBridge({ teamName, text });
517
+ };
398
518
 
399
519
  async function readSavedTaskBusConfig(): Promise<TaskBusConfig | null> {
400
520
  try {
@@ -470,6 +590,94 @@ function normalizePlatformAllowFrom(value: unknown): Record<string, string> {
470
590
  return Object.fromEntries(entries);
471
591
  }
472
592
 
593
+ function hasPlatformAllowDeleteMarker(value: unknown): boolean {
594
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
595
+ return false;
596
+ }
597
+ return Object.entries(value as Record<string, unknown>).some(
598
+ ([platform, allowFrom]) =>
599
+ platform.trim().length > 0 && (typeof allowFrom !== 'string' || allowFrom.trim().length === 0)
600
+ );
601
+ }
602
+
603
+ function normalizePlatformAllowUpdate(value: unknown): Record<string, string> | undefined {
604
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
605
+ return undefined;
606
+ }
607
+ const normalized = normalizePlatformAllowFrom(value);
608
+ if (Object.keys(normalized).length > 0) {
609
+ if (normalized.lark !== undefined) delete normalized.feishu;
610
+ return normalized;
611
+ }
612
+ return Object.keys(value).length === 0 || hasPlatformAllowDeleteMarker(value) ? {} : undefined;
613
+ }
614
+
615
+ function readStringOption(record: Record<string, unknown>, keys: readonly string[]): string {
616
+ for (const key of keys) {
617
+ const value = record[key];
618
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim();
619
+ }
620
+ return '';
621
+ }
622
+
623
+ async function persistPlatformRoutingMetadataForProject(
624
+ projectName: string,
625
+ platformType: string,
626
+ options: Record<string, unknown>
627
+ ): Promise<void> {
628
+ const project = projectName.trim();
629
+ const platform = platformType.trim();
630
+ if (!project || !platform) return;
631
+
632
+ const allowFrom = readStringOption(options, [
633
+ 'allow_from',
634
+ 'owner_open_id',
635
+ 'owner_user_id',
636
+ 'owner_union_id',
637
+ 'user_id',
638
+ 'open_id',
639
+ ]);
640
+ const explicitAllowChat = readStringOption(options, ['allow_chat', 'chat_id', 'open_chat_id']);
641
+ const allowChat = explicitAllowChat || (allowFrom ? '*' : '');
642
+ if (!allowFrom && !allowChat) return;
643
+
644
+ let teamSlug: string;
645
+ try {
646
+ const manifest = await svc.readTeamManifestByProject(project);
647
+ teamSlug = manifest.slug || project;
648
+ } catch {
649
+ teamSlug = project === SYSTEM_MANAGER_BIND_PROJECT ? SYSTEM_MANAGER_TEAM_NAME : project;
650
+ }
651
+
652
+ let existingFrom: Record<string, string> = {};
653
+ let existingChat: Record<string, string> = {};
654
+ try {
655
+ const manifest = await svc.readTeamManifest(teamSlug);
656
+ existingFrom = normalizePlatformAllowFrom(manifest.platformAllowFrom);
657
+ existingChat = normalizePlatformAllowFrom(manifest.platformAllowChat);
658
+ } catch {
659
+ // Team metadata may not exist for a cc-connect-only project yet.
660
+ }
661
+
662
+ const patch: Record<string, unknown> = {};
663
+ if (allowFrom) patch.platformAllowFrom = { ...existingFrom, [platform]: allowFrom };
664
+ if (allowChat) patch.platformAllowChat = { ...existingChat, [platform]: allowChat };
665
+
666
+ try {
667
+ await svc.updateTeam(teamSlug, patch);
668
+ } catch (err) {
669
+ app.log.warn(
670
+ { err, project, teamSlug, platform },
671
+ 'failed to persist platform routing metadata'
672
+ );
673
+ }
674
+ }
675
+
676
+ function isCcProjectNotFoundError(err: unknown): boolean {
677
+ const message = err instanceof Error ? err.message : String(err);
678
+ return /project not found:/i.test(message);
679
+ }
680
+
473
681
  // ===========================================================================
474
682
  // SSE 客户端管理器 — 广播 bridge 事件到所有连接的前端客户端
475
683
  // ===========================================================================
@@ -491,11 +699,148 @@ function broadcastSse(eventName: string, data: unknown): void {
491
699
  // 启动 bridge 并把事件广播到 SSE 客户端
492
700
  bridge.start();
493
701
 
702
+ // ---------------------------------------------------------------------------
703
+ // Direct-CLI execution layer.
704
+ // In-app Loop consoles (admin + team lead) and team-member DMs spawn the local
705
+ // `claude` CLI directly as a long-lived stream-json subprocess, bypassing
706
+ // cc-connect (which is now reserved for external IM). cc-connect's project/
707
+ // work_dir/platform layer was the root cause of "❌ 错误: 启动 Agent 会话失败".
708
+ // Manager events relay to SSE for token-level streaming; the `result` event
709
+ // persists the final reply into the team inbox (same appendMessage path as the
710
+ // bridge reply handler), so the existing renderer refresh Just Works.
711
+ // ---------------------------------------------------------------------------
712
+ const directCliManager = new DirectCliSessionManager();
713
+
714
+ /** Routes a sessionKey → the team inbox + reply sender/recipient it belongs to. */
715
+ interface DirectCliRoute {
716
+ teamName: string;
717
+ /** `from` value persisted on the assistant reply (team name for lead, member name for DM). */
718
+ from: string;
719
+ to: string;
720
+ }
721
+
722
+ const directCliRoutes = new Map<string, DirectCliRoute>();
723
+
724
+ // Per-team tool-approval settings (auto-allow categories). Synced from the renderer on
725
+ // startup via /api/teams/:name/tool-approval/settings. Defaults deny everything so the user
726
+ // is prompted — matching Claude Code's native cmd permission flow.
727
+ const toolApprovalSettingsByName = new Map<string, ToolApprovalSettings>();
728
+
729
+ /**
730
+ * Maps a permission requestId → the DirectCli session it came from (lead or member DM), plus
731
+ * the toolName/toolInput needed to build the AskUserQuestion `updatedInput` at respond time.
732
+ */
733
+ interface PendingPermissionApproval {
734
+ sessionKey: string;
735
+ toolName?: string;
736
+ toolInput?: Record<string, unknown>;
737
+ }
738
+ const permissionSessionByRequestId = new Map<string, PendingPermissionApproval>();
739
+
740
+ function readToolApprovalSettings(teamName: string): ToolApprovalSettings {
741
+ return toolApprovalSettingsByName.get(teamName) ?? DEFAULT_TOOL_APPROVAL_SETTINGS;
742
+ }
743
+
744
+ // Auto-allow rules (autoAllowAll / file edits / safe-but-not-dangerous bash) live in the
745
+ // shared, unit-tested `toolApprovalRules` util — copied verbatim from the multi-agent
746
+ // reference impl so the rule set (incl. DANGEROUS_PATTERNS that override safe prefixes,
747
+ // e.g. `git rm`) stays byte-identical. Only `can_use_tool` is a real gate; other control
748
+ // subtypes must be auto-allowed or the stream deadlocks on stdin.
749
+
750
+ directCliManager.on('event', (event: DirectCliEvent) => {
751
+ const route = directCliRoutes.get(event.sessionKey);
752
+ if (!route) return;
753
+ const { teamName } = route;
754
+
755
+ if (event.kind === 'complete') {
756
+ void (async () => {
757
+ if (event.text) {
758
+ await svc
759
+ .appendMessage(teamName, {
760
+ // Carry the streaming messageId as the canonical id so the renderer's
761
+ // optimistic in-progress reply (same messageId) is pruned, not duplicated.
762
+ id: event.messageId,
763
+ from: route.from,
764
+ to: route.to,
765
+ role: 'agent',
766
+ content: event.text,
767
+ meta: { sessionKey: event.sessionKey, source: 'direct-cli' },
768
+ })
769
+ .catch((err) =>
770
+ app.log.warn({ err, sessionKey: event.sessionKey }, 'direct-cli append failed')
771
+ );
772
+ }
773
+ broadcastSse('team-change', { type: 'inbox', teamName });
774
+ })();
775
+ return;
776
+ }
777
+
778
+ if (event.kind === 'error') {
779
+ app.log.warn({ error: event.error, sessionKey: event.sessionKey }, 'direct-cli session error');
780
+ broadcastSse('team-change', { type: 'inbox', teamName });
781
+ return;
782
+ }
783
+
784
+ if (event.kind === 'permission-request') {
785
+ void (async () => {
786
+ const settings = readToolApprovalSettings(teamName);
787
+ // Non-`can_use_tool` subtypes (hook_callback, etc.) auto-allow to prevent deadlock;
788
+ // `can_use_tool` goes through the shared shouldAutoAllow rules.
789
+ const autoAllow =
790
+ event.subtype !== 'can_use_tool' ||
791
+ shouldAutoAllow(settings, event.toolName ?? 'Unknown', event.toolInput ?? {}).autoAllow;
792
+ if (autoAllow) {
793
+ try {
794
+ directCliManager.respondPermission(event.sessionKey, event.requestId, true);
795
+ } catch (err) {
796
+ app.log.warn(
797
+ { err, sessionKey: event.sessionKey },
798
+ 'direct-cli auto-allow respond failed'
799
+ );
800
+ }
801
+ return;
802
+ }
803
+ // Surface to the renderer's CC-style approval sheet (Allow / Deny / Allow all). The
804
+ // user's choice comes back via /api/teams/:name/tool-approval/respond, which writes
805
+ // the control_response to stdin and unblocks the turn.
806
+ permissionSessionByRequestId.set(event.requestId, {
807
+ sessionKey: event.sessionKey,
808
+ toolName: event.toolName,
809
+ toolInput: event.toolInput,
810
+ });
811
+ broadcastSse('tool-approval-event', {
812
+ requestId: event.requestId,
813
+ runId: event.runId,
814
+ teamName,
815
+ source: 'lead',
816
+ toolName: event.toolName ?? 'Unknown',
817
+ toolInput: event.toolInput ?? {},
818
+ receivedAt: new Date().toISOString(),
819
+ } satisfies ToolApprovalRequest);
820
+ })();
821
+ return;
822
+ }
823
+
824
+ // init / delta / thinking / tool → live streaming payload for the renderer.
825
+ broadcastSse('team-change', {
826
+ type: 'direct-cli-stream',
827
+ teamName,
828
+ sessionKey: event.sessionKey,
829
+ messageId: 'messageId' in event ? event.messageId : undefined,
830
+ kind: event.kind,
831
+ text: 'text' in event ? event.text : undefined,
832
+ toolName: 'toolName' in event ? event.toolName : undefined,
833
+ toolInput: 'toolInput' in event ? event.toolInput : undefined,
834
+ from: route.from,
835
+ });
836
+ });
837
+
494
838
  bridge.on('reply', (msg) => {
495
839
  const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
496
- const teamName = resolveTeamFromSessionKey(sessionKey) ?? sessionKey;
497
840
 
498
841
  void (async () => {
842
+ const teamName = await resolveTeamFromBridgeMessageWithRetry(msg);
843
+ if (!teamName) return;
499
844
  // 先落盘再广播,否则前端可能在 appendFile 完成前刷新到旧 feed。
500
845
  await svc.appendMessage(teamName, {
501
846
  from: teamName,
@@ -506,19 +851,20 @@ bridge.on('reply', (msg) => {
506
851
  });
507
852
  broadcastSse('team-change', { type: 'inbox', teamName });
508
853
  })().catch((err) => {
509
- app.log.warn({ err, teamName, sessionKey }, 'bridge reply persistence failed');
854
+ app.log.warn({ err, sessionKey }, 'bridge reply persistence failed');
510
855
  });
511
856
  });
512
857
 
513
858
  bridge.on('reply_stream', (msg) => {
514
859
  const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
515
- const teamName = resolveTeamFromSessionKey(sessionKey) ?? sessionKey;
516
860
  const done = (msg as { done?: boolean }).done ?? false;
517
861
 
518
- if (done) {
519
- // 流式结束,存储完整回复
520
- const fullText = (msg as { full_text?: string }).full_text ?? '';
521
- 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 ?? '';
522
868
  if (fullText) {
523
869
  await svc.appendMessage(teamName, {
524
870
  from: teamName,
@@ -529,43 +875,148 @@ bridge.on('reply_stream', (msg) => {
529
875
  });
530
876
  }
531
877
  broadcastSse('team-change', { type: 'inbox', teamName });
532
- })().catch((err) => {
533
- app.log.warn({ err, teamName, sessionKey }, 'bridge stream reply persistence failed');
534
- });
535
- } else {
878
+ return;
879
+ }
536
880
  broadcastSse('team-change', { type: 'lead-message', teamName });
537
- }
881
+ })().catch((err) => {
882
+ app.log.warn({ err, sessionKey }, 'bridge stream reply persistence failed');
883
+ });
538
884
  });
539
885
 
540
886
  bridge.on('message', (msg) => {
541
887
  const type = (msg as { type?: string }).type ?? '';
542
888
  const sessionKey: string = (msg as { session_key?: string }).session_key ?? '';
543
889
  if (!sessionKey) return; // 无 session_key 的控制帧(pong 等)不广播
544
- const teamName = resolveTeamFromSessionKey(sessionKey);
545
- if (!teamName) return;
546
- // typing_start/stop lead-message;其他 → inbox
547
- const eventType = type === 'typing_start' || type === 'typing_stop' ? 'lead-message' : 'inbox';
548
- 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
+ });
549
900
  });
550
901
 
902
+ const BRIDGE_SESSION_TEAM_CACHE_TTL_MS = 60_000;
903
+ const EXTERNAL_PLATFORM_ROUTE_RETRY_COUNT = 6;
904
+ const EXTERNAL_PLATFORM_ROUTE_RETRY_DELAY_MS = 1_000;
905
+ const bridgeSessionTeamCache = new Map<string, { teamName: string; expiresAt: number }>();
906
+
551
907
  /**
552
- * 从 session_key 解析 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。
553
1008
  * 约定格式:
554
1009
  * hermit:{teamName}:session (老格式)
555
1010
  * hermit:{teamName}:lead (新格式)
556
1011
  * bridge:hermit-{team}:{member}
557
- * {teamName} (直接就是 teamName)
558
1012
  */
559
- function resolveTeamFromSessionKey(sessionKey: string): string | null {
1013
+ function parseHermitTeamFromSessionKey(sessionKey: string): string | null {
560
1014
  if (!sessionKey) return null;
561
- // hermit:{teamName}:xxx
562
1015
  const hermitMatch = sessionKey.match(/^hermit:([^:]+):/);
563
1016
  if (hermitMatch) return hermitMatch[1];
564
- // bridge:hermit-{team}:{member}
565
1017
  const bridgeMatch = sessionKey.match(/^bridge:hermit-([^:]+):/);
566
1018
  if (bridgeMatch) return bridgeMatch[1];
567
- // 否则当成 teamName 本身
568
- return sessionKey;
1019
+ return null;
569
1020
  }
570
1021
 
571
1022
  const app = Fastify({
@@ -591,9 +1042,22 @@ const allowedCorsOrigins = configuredCorsOrigins?.length
591
1042
  ];
592
1043
  const allowedOriginSet = new Set(allowedCorsOrigins);
593
1044
 
1045
+ function isLoopbackBrowserOrigin(origin: string): boolean {
1046
+ try {
1047
+ const parsed = new URL(origin);
1048
+ return (
1049
+ (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
1050
+ ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)
1051
+ );
1052
+ } catch {
1053
+ return false;
1054
+ }
1055
+ }
1056
+
594
1057
  function isTrustedBrowserOrigin(origin: string | undefined): boolean {
595
1058
  if (!origin) return true;
596
- return allowedOriginSet.has(origin);
1059
+ if (allowedOriginSet.has(origin)) return true;
1060
+ return isLoopbackBrowserOrigin(origin);
597
1061
  }
598
1062
 
599
1063
  function assertTrustedBrowserOrigin(request: import('fastify').FastifyRequest): void {
@@ -661,17 +1125,15 @@ async function proxyToCcConnect(
661
1125
  );
662
1126
  return reply.code(upstream.status).send({
663
1127
  ok: false,
664
- error: `cc-connect 端点 ${subPath} 返回了非 JSON 响应 (HTTP ${upstream.status})。` +
1128
+ error:
1129
+ `cc-connect 端点 ${subPath} 返回了非 JSON 响应 (HTTP ${upstream.status})。` +
665
1130
  '请检查 cc-connect 是否正在运行且支持该端点。',
666
1131
  });
667
1132
  }
668
1133
 
669
1134
  return reply
670
1135
  .code(upstream.status)
671
- .header(
672
- 'Content-Type',
673
- contentType || 'application/json; charset=utf-8'
674
- )
1136
+ .header('Content-Type', contentType || 'application/json; charset=utf-8')
675
1137
  .send(body);
676
1138
  }
677
1139
 
@@ -991,18 +1453,8 @@ app.patch<{ Body: Record<string, unknown> }>('/api/cc-settings', async (request)
991
1453
  // restart / reload cc-connect
992
1454
  app.post('/api/cc-restart', async () => {
993
1455
  try {
994
- await cc.restart();
995
- // Wait for cc-connect to come back (restart only signals, process respawns async)
996
- for (let i = 0; i < 30; i++) {
997
- await new Promise((r) => setTimeout(r, 1000));
998
- try {
999
- await cc.listProjects();
1000
- return { ok: true };
1001
- } catch {
1002
- /* not back yet */
1003
- }
1004
- }
1005
- return reply500(new Error('cc-connect did not come back within 30s'));
1456
+ await restartCcConnectAndReconnectBridge();
1457
+ return { ok: true };
1006
1458
  } catch (err) {
1007
1459
  return reply500(err);
1008
1460
  }
@@ -1021,10 +1473,14 @@ app.post('/api/cc-reload', async () => {
1021
1473
  // Teams — cc-connect projects 即团队,本地 ~/.hermit/teams/ 仅存 tasks + 额外元数据
1022
1474
  // ===========================================================================
1023
1475
 
1024
- // POST /api/system-manager/ensure → 确保项目级控制台存在
1476
+ // POST /api/system-manager/ensure → 确保项目级 Helm Loop存在
1025
1477
  app.post('/api/system-manager/ensure', async (_request, reply) => {
1026
1478
  try {
1027
- 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;
1028
1484
  } catch (err) {
1029
1485
  return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
1030
1486
  }
@@ -1041,12 +1497,10 @@ app.get('/api/system-manager/status', async (_request, reply) => {
1041
1497
  app.get('/api/system-manager/config', async (_request, reply) => {
1042
1498
  try {
1043
1499
  const config = await systemManagerConfig.getConfig();
1044
- // Seed builtin workflows into workspace if missing
1045
- const workspaceWorkflows = config.selectedWorkDir
1046
- ? path.join(config.selectedWorkDir, 'workflows')
1047
- : undefined;
1048
- if (workspaceWorkflows) {
1049
- void seedBuiltinWorkflows(workspaceWorkflows);
1500
+ // Seed builtin commands into workspace's .claude/commands/ if missing.
1501
+ // Await so the control console can list quick workflow buttons immediately.
1502
+ if (config.selectedWorkDir) {
1503
+ await seedBuiltinWorkflows(config.selectedWorkDir);
1050
1504
  }
1051
1505
  return config;
1052
1506
  } catch (err) {
@@ -1059,7 +1513,7 @@ app.put<{ Body: { selectedWorkDir?: string; workflowFolder?: string | null } }>(
1059
1513
  async (request, reply) => {
1060
1514
  try {
1061
1515
  const config = await systemManagerConfig.updateConfig(request.body ?? {});
1062
- await syncSystemManagerManifestWorkDir(config.selectedWorkDir);
1516
+ await seedBuiltinWorkflows(config.selectedWorkDir);
1063
1517
  return config;
1064
1518
  } catch (err) {
1065
1519
  return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) });
@@ -1079,126 +1533,157 @@ app.post<{ Body: { folder?: string } }>(
1079
1533
  const result = await workflowPromptService.list(folder);
1080
1534
  await systemManagerConfig.updateConfig({ workflowFolder: result.folder });
1081
1535
  return result;
1082
- } catch {
1536
+ } catch (err) {
1537
+ const message = err instanceof Error ? err.message : String(err);
1538
+ if (message.startsWith('Forbidden origin:')) {
1539
+ return reply.code(403).send({ error: message });
1540
+ }
1083
1541
  return { folder: '', prompts: [], warnings: [] };
1084
1542
  }
1085
1543
  }
1086
1544
  );
1087
1545
 
1088
- app.post<{ Body: { id?: string } }>(
1546
+ app.post<{ Body: { folder?: string; id?: string } }>(
1089
1547
  '/api/system-manager/workflows/read',
1090
1548
  async (request, reply) => {
1091
1549
  try {
1092
1550
  assertTrustedBrowserOrigin(request);
1093
1551
  const config = await systemManagerConfig.getConfig();
1094
- if (!config.workflowFolder)
1095
- 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' });
1096
1557
  const id = typeof request.body?.id === 'string' ? request.body.id : '';
1097
- return await workflowPromptService.read(config.workflowFolder, id);
1558
+ return await workflowPromptService.read(folder, id);
1098
1559
  } catch (err) {
1099
1560
  return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) });
1100
1561
  }
1101
1562
  }
1102
1563
  );
1103
1564
 
1104
- app.post<{ Body: { command?: string; args?: string[]; cwd?: string; cols?: number; rows?: number } }>(
1105
- '/api/terminal/spawn',
1106
- async (request, reply) => {
1107
- try {
1108
- assertTrustedBrowserOrigin(request);
1109
- const requestedCwd = typeof request.body?.cwd === 'string' ? request.body.cwd.trim() : '';
1110
- const command = typeof request.body?.command === 'string' ? request.body.command : 'claude';
1111
- const args = Array.isArray(request.body?.args) ? request.body.args : [];
1112
-
1113
- // Use requested cwd if provided; otherwise fall back to system manager config
1114
- let cwd: string;
1115
- if (requestedCwd) {
1116
- cwd = requestedCwd;
1117
- // Update system manager config to track the work dir
1118
- await systemManagerConfig.updateConfig({ selectedWorkDir: cwd });
1119
- await syncSystemManagerManifestWorkDir(cwd);
1120
- } else {
1121
- const config = await systemManagerConfig.getConfig();
1122
- cwd = config.selectedWorkDir;
1123
- }
1565
+ function shellQuote(value: string): string {
1566
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
1567
+ }
1124
1568
 
1125
- const ptyId = await systemManagerPty.spawn({
1126
- command,
1127
- args,
1128
- cwd,
1129
- cols: request.body?.cols,
1130
- rows: request.body?.rows,
1131
- });
1132
- return { ptyId };
1133
- } catch (err) {
1134
- const message = err instanceof Error ? err.message : String(err);
1135
- return reply
1136
- .code(message.startsWith('Forbidden origin:') ? 403 : 500)
1137
- .send({ error: message });
1138
- }
1569
+ function escapeAppleScriptString(value: string): string {
1570
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1571
+ }
1572
+
1573
+ function appleScriptStringLiteral(value: string): string {
1574
+ return value
1575
+ .replace(/\r\n/g, '\n')
1576
+ .replace(/\r/g, '\n')
1577
+ .split('\n')
1578
+ .map((line) => `"${escapeAppleScriptString(line)}"`)
1579
+ .join(' & linefeed & ');
1580
+ }
1581
+
1582
+ function execFileAsync(file: string, args: string[]): Promise<void> {
1583
+ return new Promise((resolve, reject) => {
1584
+ void import('node:child_process')
1585
+ .then(({ execFile }) => {
1586
+ execFile(file, args, (error) => {
1587
+ if (error) {
1588
+ reject(error);
1589
+ return;
1590
+ }
1591
+ resolve();
1592
+ });
1593
+ })
1594
+ .catch(reject);
1595
+ });
1596
+ }
1597
+
1598
+ function spawnDetached(file: string, args: string[]): Promise<void> {
1599
+ return new Promise((resolve, reject) => {
1600
+ void import('node:child_process')
1601
+ .then(({ spawn }) => {
1602
+ const child = spawn(file, args, { detached: true, stdio: 'ignore' });
1603
+ child.once('error', reject);
1604
+ child.once('spawn', () => {
1605
+ child.unref();
1606
+ resolve();
1607
+ });
1608
+ })
1609
+ .catch(reject);
1610
+ });
1611
+ }
1612
+
1613
+ function cmdQuote(value: string): string {
1614
+ return `"${value.replace(/"/g, '\\"')}"`;
1615
+ }
1616
+
1617
+ // Launches commands in an external/system terminal only; no embedded terminal mode.
1618
+ async function openCommandInSystemTerminal(
1619
+ shellLine: string,
1620
+ windowsShellLine: string
1621
+ ): Promise<void> {
1622
+ if (process.platform === 'darwin') {
1623
+ const script = `tell application "Terminal"\ndo script ${appleScriptStringLiteral(shellLine)}\nactivate\nend tell`;
1624
+ await execFileAsync('osascript', ['-e', script]);
1625
+ return;
1139
1626
  }
1140
- );
1141
1627
 
1142
- app.post<{ Params: { ptyId: string }; Body: { data?: string } }>(
1143
- '/api/terminal/:ptyId/write',
1144
- async (request, reply) => {
1145
- try {
1146
- assertTrustedBrowserOrigin(request);
1147
- systemManagerPty.write(request.params.ptyId, request.body?.data ?? '');
1148
- return { ok: true };
1149
- } catch (err) {
1150
- return reply.code(404).send({ error: err instanceof Error ? err.message : String(err) });
1151
- }
1628
+ if (process.platform === 'win32') {
1629
+ await spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', windowsShellLine]);
1630
+ return;
1152
1631
  }
1153
- );
1154
1632
 
1155
- app.post<{ Params: { ptyId: string }; Body: { cols?: number; rows?: number } }>(
1156
- '/api/terminal/:ptyId/resize',
1157
- 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) {
1158
1648
  try {
1159
- assertTrustedBrowserOrigin(request);
1160
- systemManagerPty.resize(
1161
- request.params.ptyId,
1162
- request.body?.cols ?? 120,
1163
- request.body?.rows ?? 34
1164
- );
1165
- return { ok: true };
1649
+ await spawnDetached(candidate.file, candidate.args);
1650
+ return;
1166
1651
  } catch (err) {
1167
- 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)}`);
1168
1653
  }
1169
1654
  }
1170
- );
1171
-
1172
- app.delete<{ Params: { ptyId: string } }>('/api/terminal/:ptyId', async (request, reply) => {
1173
- try {
1174
- assertTrustedBrowserOrigin(request);
1175
- await systemManagerPty.kill(request.params.ptyId);
1176
- return { ok: true };
1177
- } catch (err) {
1178
- return reply.code(403).send({ error: err instanceof Error ? err.message : String(err) });
1179
- }
1180
- });
1655
+ throw new Error(`No system terminal launcher succeeded. ${errors.join('; ')}`);
1656
+ }
1181
1657
 
1182
- // POST /api/terminal/open-external — open command in system Terminal.app
1658
+ // POST /api/terminal/open-external — open command in an external/system terminal
1183
1659
  app.post<{ Body: { command: string; args?: string[]; cwd?: string } }>(
1184
1660
  '/api/terminal/open-external',
1185
1661
  async (request, reply) => {
1186
1662
  try {
1663
+ assertTrustedBrowserOrigin(request);
1187
1664
  const { command, args = [], cwd } = request.body ?? {};
1188
1665
  if (!command) return reply.code(400).send({ error: 'command is required' });
1189
- const cmd = [command, ...args].join(' ');
1190
- const script = cwd
1191
- ? `tell application "Terminal"\ndo script "cd ${cwd.replace(/"/g, '\\"')} && ${cmd.replace(/"/g, '\\"')}"\nactivate\nend tell`
1192
- : `tell application "Terminal"\ndo script "${cmd.replace(/"/g, '\\"')}"\nactivate\nend tell`;
1193
- const { execFile } = await import('node:child_process');
1194
- 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);
1195
1674
  return { ok: true };
1196
1675
  } catch (err) {
1197
- 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 });
1198
1680
  }
1199
1681
  }
1200
1682
  );
1201
1683
 
1684
+ // Worker Society REST 路由(/api/society/*)—— worker 自治社会的 HTTP 接口(workers/needs/social/feed)。
1685
+ registerSocietyRoutes(app, workerSociety);
1686
+
1202
1687
  // GET /api/teams → Hermit 本地团队优先,裸 cc-connect project 作为历史兼容显示;过滤飞书/系统项目
1203
1688
  app.get('/api/teams', async () => {
1204
1689
  try {
@@ -1224,21 +1709,14 @@ app.get('/api/teams', async () => {
1224
1709
  .map(async (meta) => {
1225
1710
  const bindProject = meta.bindProject || meta.slug;
1226
1711
  const project = projectByName.get(bindProject);
1227
- const isOnline = Array.isArray(project?.platforms) && project.platforms.length > 0;
1228
- let workDir = (meta.workDir || '').trim();
1229
- if (!workDir && project) {
1230
- try {
1231
- const detail = await cc.getProject(bindProject);
1232
- if (typeof detail.work_dir === 'string' && detail.work_dir.trim()) {
1233
- workDir = detail.work_dir.trim();
1234
- }
1235
- } catch {
1236
- // ignore detail read failure, keep manifest/default path
1237
- }
1238
- }
1712
+ // Keep the list endpoint fast: per-team cc.getProject calls are slow and
1713
+ // block first paint. Runtime liveness is loaded separately via aliveList.
1714
+ const workDir = (meta.workDir || '').trim();
1715
+ const projectPath = (meta.workDir || '').trim();
1239
1716
  const harness = toCcAgentType(project?.agent_type || meta.harness);
1240
1717
  const color = meta.color || 'blue';
1241
1718
  const displayName = meta.displayName || meta.slug;
1719
+ const usageStats = workDir ? getProjectStatsSnapshot(workDir) : null;
1242
1720
 
1243
1721
  return {
1244
1722
  teamName: meta.slug,
@@ -1249,16 +1727,27 @@ app.get('/api/teams', async () => {
1249
1727
  members: [{ name: displayName, role: 'agent', agentId: harness, color }],
1250
1728
  taskCount: 0,
1251
1729
  lastActivity: null,
1252
- isAlive: isOnline,
1730
+ isAlive: false,
1253
1731
  harness,
1254
1732
  bindProject,
1255
1733
  workDir,
1256
- projectPath: workDir || undefined,
1734
+ projectPath: projectPath || undefined,
1257
1735
  sessionsCount: project?.sessions_count ?? 0,
1258
1736
  heartbeatEnabled: project?.heartbeat_enabled ?? false,
1259
1737
  pendingDelete: meta.pendingDelete === true,
1260
1738
  restartRequired: meta.restartRequired === true,
1261
- stats: 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,
1262
1751
  };
1263
1752
  })
1264
1753
  );
@@ -1453,7 +1942,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
1453
1942
  disabledCommands: resolvedDisabledCommands,
1454
1943
  platformAllowFrom: resolvedPlatformAllowFrom,
1455
1944
  platformAllowChat: resolvedPlatformAllowChat,
1456
- projectPath: p.work_dir ?? workDir,
1945
+ projectPath: workDir || p.work_dir,
1457
1946
  members: [{ name: displayName, role: 'lead' }],
1458
1947
  },
1459
1948
  tasks: teamTasks,
@@ -1511,6 +2000,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
1511
2000
  managedSources,
1512
2001
  disabledCommands,
1513
2002
  platformAllowFrom,
2003
+ platformAllowChat,
1514
2004
  projectPath: workDir,
1515
2005
  members: [{ name: displayName, role: 'lead' }],
1516
2006
  },
@@ -1547,6 +2037,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/data', async (request, r
1547
2037
  reply_footer: replyFooter,
1548
2038
  inject_sender: injectSender,
1549
2039
  platform_allow_from: platformAllowFrom,
2040
+ platform_allow_chat: platformAllowChat,
1550
2041
  },
1551
2042
  activeSessions: [],
1552
2043
  };
@@ -1572,21 +2063,39 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
1572
2063
  async (request, reply) => {
1573
2064
  const teamName = request.params.name;
1574
2065
  if (isReservedSystemTeamName(teamName)) {
1575
- return reply.code(403).send({ error: '控制台不可删除' });
2066
+ return reply.code(403).send({ error: 'Helm Loop 不可删除' });
1576
2067
  }
1577
2068
  try {
1578
2069
  let restartRequired = false;
2070
+ let ccProjectName = teamName;
2071
+ let localTeamName = teamName;
1579
2072
  try {
1580
- const 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);
1581
2084
  restartRequired = result.restart_required === true;
1582
2085
  } catch (err) {
1583
- 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
+ );
1584
2090
  }
1585
2091
 
1586
2092
  try {
1587
- await svc.deleteTeam(teamName, { deleteFiles: request.query.deleteFiles === 'true' });
2093
+ await svc.deleteTeam(localTeamName, { deleteFiles: request.query.deleteFiles === 'true' });
1588
2094
  } catch (err) {
1589
- 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
+ );
1590
2099
  }
1591
2100
 
1592
2101
  return { ok: true, restartRequired };
@@ -1599,7 +2108,7 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
1599
2108
  // ===========================================================================
1600
2109
  // Tasks — 存储在 ~/.hermit/teams/:name/tasks/board.json
1601
2110
  // 双向映射:TeamTask(pending/in_progress/completed) ↔ Task(todo/doing/done)
1602
- // assignee 变化时触发 Task Dispatcher(Bridge 推消息给目标团队 agent)
2111
+ // 任务创建/指派只更新看板;只有显式点击开始才投递给 runtime/目标团队。
1603
2112
  // ===========================================================================
1604
2113
 
1605
2114
  /** TeamTask status → internal Task status */
@@ -1609,6 +2118,13 @@ function toTaskStatus(s: string): 'todo' | 'doing' | 'done' {
1609
2118
  return 'todo';
1610
2119
  }
1611
2120
 
2121
+ function isManualInProgressExitBlocked(
2122
+ currentStatus: string | undefined,
2123
+ nextStatus: 'todo' | 'doing' | 'done' | undefined
2124
+ ): boolean {
2125
+ return currentStatus === 'doing' && nextStatus !== undefined && nextStatus !== 'doing';
2126
+ }
2127
+
1612
2128
  /** internal Task → TeamTask shape (for UI consumption) */
1613
2129
  function toTeamTask(task: {
1614
2130
  id: string;
@@ -1651,36 +2167,6 @@ function activeTasks<T extends { result?: string | null }>(tasks: T[]): T[] {
1651
2167
  return tasks.filter((task) => !isSoftDeletedTask(task));
1652
2168
  }
1653
2169
 
1654
- function mapCcSessionDetail(detail: {
1655
- id: string;
1656
- name: string;
1657
- session_key: string;
1658
- agent_session_id?: string;
1659
- agent_type: string;
1660
- active: boolean;
1661
- live: boolean;
1662
- history_count: number;
1663
- created_at: string;
1664
- updated_at: string;
1665
- platform: string;
1666
- history: { role: 'user' | 'assistant'; content: string; timestamp: string }[];
1667
- }) {
1668
- return {
1669
- id: detail.id,
1670
- name: detail.name,
1671
- sessionKey: detail.session_key,
1672
- agentSessionId: detail.agent_session_id,
1673
- agentType: detail.agent_type,
1674
- active: detail.active,
1675
- live: detail.live,
1676
- historyCount: detail.history_count,
1677
- createdAt: detail.created_at,
1678
- updatedAt: detail.updated_at,
1679
- platform: detail.platform,
1680
- history: detail.history ?? [],
1681
- };
1682
- }
1683
-
1684
2170
  app.get<{ Params: { name: string } }>('/api/teams/:name/tasks', async (request) => {
1685
2171
  try {
1686
2172
  const tasks = activeTasks(await svc.readTasks(request.params.name));
@@ -1703,33 +2189,34 @@ app.post<{ Params: { name: string }; Body: Record<string, unknown> }>(
1703
2189
  assignee: (body.owner ?? body.assignee) as string | null | undefined,
1704
2190
  status: body.status ? toTaskStatus(body.status as string) : 'todo',
1705
2191
  });
1706
- if (task.assignee) {
1707
- svc
1708
- .dispatchTask(request.params.name, task)
1709
- .catch((err) => request.log.warn({ err }, 'dispatchTask failed'));
1710
- }
1711
2192
  return toTeamTask(task);
1712
2193
  }
1713
2194
  );
1714
2195
 
1715
2196
  app.patch<{ Params: { name: string; id: string }; Body: Record<string, unknown> }>(
1716
2197
  '/api/teams/:name/tasks/:id',
1717
- async (request) => {
2198
+ async (request, reply) => {
1718
2199
  const body = request.body ?? {};
1719
2200
  const patch: Record<string, unknown> = {};
2201
+ const nextStatus = body.status !== undefined ? toTaskStatus(body.status as string) : undefined;
1720
2202
  if (body.subject !== undefined) patch.title = body.subject;
1721
2203
  if (body.title !== undefined) patch.title = body.title;
1722
2204
  if (body.description !== undefined) patch.description = body.description;
1723
- if (body.status !== undefined) patch.status = toTaskStatus(body.status as string);
2205
+ if (nextStatus !== undefined) patch.status = nextStatus;
1724
2206
  if (body.owner !== undefined) patch.assignee = body.owner;
1725
2207
  if (body.assignee !== undefined) patch.assignee = body.assignee;
1726
2208
  if (body.result !== undefined) patch.result = body.result;
1727
- const task = await svc.patchTask(request.params.name, request.params.id, patch);
1728
- if (patch.assignee && task.assignee) {
1729
- svc
1730
- .dispatchTask(request.params.name, task)
1731
- .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
+ });
1732
2217
  }
2218
+
2219
+ const task = await svc.patchTask(request.params.name, request.params.id, patch);
1733
2220
  return toTeamTask(task);
1734
2221
  }
1735
2222
  );
@@ -1738,6 +2225,14 @@ app.delete<{ Params: { name: string; id: string } }>(
1738
2225
  '/api/teams/:name/tasks/:id',
1739
2226
  async (request, reply) => {
1740
2227
  try {
2228
+ const tasks = await svc.readTasks(request.params.name);
2229
+ const existingTask = tasks.find((task) => task.id === request.params.id);
2230
+ if (existingTask?.status === 'doing') {
2231
+ return reply.code(409).send({
2232
+ ok: false,
2233
+ error: 'Agent 正在处理中,不能手动删除任务。',
2234
+ });
2235
+ }
1741
2236
  await svc.patchTask(request.params.name, request.params.id, {
1742
2237
  status: 'done',
1743
2238
  result: '__deleted__',
@@ -1870,7 +2365,279 @@ app.get('/api/harnesses', async () => {
1870
2365
  }));
1871
2366
  } catch {
1872
2367
  // cc-connect 不可达时返回完整枚举列表
1873
- return CC_AGENT_TYPES.map((type) => ({ 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) });
1874
2641
  }
1875
2642
  });
1876
2643
 
@@ -1888,12 +2655,12 @@ app.post<{ Params: { name: string }; Body: Partial<TeamLaunchRequest> }>(
1888
2655
  const body = request.body ?? {};
1889
2656
  let manifest: TeamManifest | null = null;
1890
2657
  try {
1891
- manifest = await svc.readTeamManifest(name);
2658
+ manifest = await svc.readTeamManifestByProject(name);
1892
2659
  } catch {
1893
2660
  // Team may only exist in cc-connect.
1894
2661
  }
1895
2662
  const bindProject = manifest?.bindProject ?? name;
1896
- const workDir = body.cwd ?? manifest?.workDir ?? '';
2663
+ let workDir = body.cwd ?? manifest?.workDir ?? '';
1897
2664
  const harness = manifest?.harness ?? 'claudecode';
1898
2665
  const platformType = manifest?.platform ?? 'bridge';
1899
2666
  const platformOptions = manifest?.platformOptions ?? {};
@@ -1921,14 +2688,21 @@ app.post<{ Params: { name: string }; Body: Partial<TeamLaunchRequest> }>(
1921
2688
  platformOptions as Record<string, string>
1922
2689
  );
1923
2690
  projectExists = true;
1924
- } catch { /* CC Connect project creation is best-effort */ }
2691
+ } catch {
2692
+ /* CC Connect project creation is best-effort */
2693
+ }
1925
2694
  }
1926
2695
  // Restart cc-connect to (re-)activate platform connections.
1927
2696
  // Covers: newly created project, existing project with disconnected platform,
1928
2697
  // Feishu/Lark IM that lost connection after cc-connect restart, etc.
1929
2698
  try {
1930
- await cc.restart();
1931
- } 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
+ }
1932
2706
  }
1933
2707
 
1934
2708
  return {
@@ -1948,7 +2722,9 @@ app.post<{ Params: { name: string } }>('/api/teams/:name/stop', async (request)
1948
2722
  // Stop = delete project from cc-connect (best-effort, no restart)
1949
2723
  try {
1950
2724
  await cc.deleteProject(bindProject);
1951
- } catch { /* project may not exist in cc-connect */ }
2725
+ } catch {
2726
+ /* project may not exist in cc-connect */
2727
+ }
1952
2728
  // Keep local team metadata intact by not deleting it
1953
2729
  // The team will show as offline (isAlive: false) on next data fetch
1954
2730
  return { ok: true };
@@ -1998,16 +2774,27 @@ app.post('/api/setup/feishu/poll', async (request, reply) => {
1998
2774
 
1999
2775
  app.post('/api/setup/feishu/save', async (request, reply) => {
2000
2776
  try {
2001
- const result = await (
2002
- await fetch(`${runtimeConfig.ccBaseUrl}/api/v1/setup/feishu/save`, {
2003
- method: 'POST',
2004
- headers: {
2005
- 'Content-Type': 'application/json',
2006
- ...(runtimeConfig.ccToken ? { Authorization: `Bearer ${runtimeConfig.ccToken}` } : {}),
2007
- },
2008
- body: JSON.stringify(request.body ?? {}),
2009
- })
2010
- ).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
+ }
2011
2798
  return result;
2012
2799
  } catch (err) {
2013
2800
  return reply500(err);
@@ -2053,16 +2840,27 @@ app.post('/api/setup/weixin/poll', async (request, reply) => {
2053
2840
 
2054
2841
  app.post('/api/setup/weixin/save', async (request, reply) => {
2055
2842
  try {
2056
- const result = await (
2057
- await fetch(`${runtimeConfig.ccBaseUrl}/api/v1/setup/weixin/save`, {
2058
- method: 'POST',
2059
- headers: {
2060
- 'Content-Type': 'application/json',
2061
- ...(runtimeConfig.ccToken ? { Authorization: `Bearer ${runtimeConfig.ccToken}` } : {}),
2062
- },
2063
- body: JSON.stringify(request.body ?? {}),
2064
- })
2065
- ).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
+ }
2066
2864
  return result;
2067
2865
  } catch (err) {
2068
2866
  return reply500(err);
@@ -2075,14 +2873,31 @@ app.post<{
2075
2873
  Body: { type: string; options?: Record<string, unknown>; work_dir?: string; agent_type?: string };
2076
2874
  }>('/api/projects/:name/add-platform', async (request, reply) => {
2077
2875
  try {
2876
+ const existingProject = await cc.getProject(request.params.name).catch(() => null);
2078
2877
  const result = await cc.createProject(
2079
2878
  request.params.name,
2080
- request.body.agent_type ?? 'claudecode',
2081
- request.body.work_dir ?? '',
2879
+ request.body.agent_type ?? existingProject?.agent_type ?? 'claudecode',
2880
+ request.body.work_dir ?? existingProject?.work_dir ?? '',
2082
2881
  request.body.type,
2083
2882
  (request.body.options ?? {}) as Record<string, string>
2084
2883
  );
2085
- 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 } };
2086
2901
  } catch (err) {
2087
2902
  return reply500(err);
2088
2903
  }
@@ -2263,6 +3078,8 @@ const MCP_TOOLS = [
2263
3078
  required: ['team_slug', 'dispatch_id', 'feedback'],
2264
3079
  },
2265
3080
  },
3081
+ // Worker Society —— 去中心化自治社会的 MCP 工具(society_* 命名空间)。
3082
+ ...SOCIETY_MCP_TOOLS,
2266
3083
  ];
2267
3084
 
2268
3085
  /** 执行 MCP tool,返回 content array */
@@ -2272,12 +3089,28 @@ async function executeMcpTool(
2272
3089
  ): Promise<{ type: string; text: string }[]> {
2273
3090
  const text = async (result: unknown) => [{ type: 'text', text: JSON.stringify(result, null, 2) }];
2274
3091
 
3092
+ // Worker Society 工具(society_*):命中即返回,未命中回退到既有派单/任务工具。
3093
+ const societyResult = await executeSocietyMcpTool(toolName, args, workerSociety);
3094
+ if (societyResult) return societyResult;
3095
+
2275
3096
  if (toolName === 'list_tasks') {
2276
3097
  const tasks = await svc.readTasks(args.team_slug);
2277
3098
  return text(tasks);
2278
3099
  }
2279
3100
 
2280
3101
  if (toolName === 'claim_task') {
3102
+ const tasks = await svc.readTasks(args.team_slug);
3103
+ const existingTask = tasks.find((task) => task.id === args.task_id);
3104
+ if (
3105
+ existingTask?.dispatchMeta &&
3106
+ existingTask.status === 'todo' &&
3107
+ ['received', 'pending_accept'].includes(existingTask.dispatchMeta.status)
3108
+ ) {
3109
+ return text({
3110
+ ok: false,
3111
+ error: 'Cross-team tasks must be started from the target team TODO board by clicking 启动.',
3112
+ });
3113
+ }
2281
3114
  const task = await svc.patchTask(args.team_slug, args.task_id, { status: 'doing' });
2282
3115
  return text(task);
2283
3116
  }
@@ -2413,8 +3246,10 @@ app.post<{
2413
3246
  // Hermit 主仓 UI 首屏强依赖的几个 stub(占位实现)
2414
3247
  // ===========================================================================
2415
3248
 
2416
- // hermit getAppVersion 期望返回字符串(不是数组),通配 stub 给的 [] 会让 JSON.parse 后类型对不上
2417
- 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
+ );
2418
3253
 
2419
3254
  // GET /api/update/check — 检查是否有新版本
2420
3255
  const updateService = new UpdateService();
@@ -2556,9 +3391,10 @@ function readAppConfig() {
2556
3391
  return mergeConfigDefaults(DEFAULT_APP_CONFIG, raw);
2557
3392
  }
2558
3393
  } catch (err) {
2559
- const msg = err instanceof SyntaxError
2560
- ? `${HERMIT_APP_CONFIG_FILE} 格式错误: ${err.message}。将使用默认配置并覆盖修复。`
2561
- : `读取 ${HERMIT_APP_CONFIG_FILE} 失败`;
3394
+ const msg =
3395
+ err instanceof SyntaxError
3396
+ ? `${HERMIT_APP_CONFIG_FILE} 格式错误: ${err.message}。将使用默认配置并覆盖修复。`
3397
+ : `读取 ${HERMIT_APP_CONFIG_FILE} 失败`;
2562
3398
  app.log.warn({ err }, msg);
2563
3399
  // Auto-heal: rewrite with valid defaults
2564
3400
  try {
@@ -2693,13 +3529,14 @@ async function sendHarnessMessageViaBridge(params: {
2693
3529
  await waitForHarnessBridgeConnected();
2694
3530
 
2695
3531
  const sessionKey = params.sessionKey?.trim() || buildFallbackSessionKey(params.teamName);
3532
+ const projectName = await resolveRouteCcProjectName(params.teamName);
2696
3533
  bridge.sendUserMessage({
2697
3534
  sessionKey,
2698
3535
  userId: 'hermit-user',
2699
3536
  userName: 'User',
2700
3537
  content: params.text,
2701
3538
  msgId: params.msgId,
2702
- project: params.teamName,
3539
+ project: projectName,
2703
3540
  });
2704
3541
  return sessionKey;
2705
3542
  }
@@ -2774,7 +3611,10 @@ function mapCronJobToSchedule(
2774
3611
  let nextRunAt: string | undefined;
2775
3612
  if (cronJob.enabled && isNonEmptyString(cronJob.cron_expr)) {
2776
3613
  try {
2777
- const job = new Cron(cronJob.cron_expr.trim(), { timezone: DEFAULT_SCHEDULE_TIMEZONE, paused: true });
3614
+ const job = new Cron(cronJob.cron_expr.trim(), {
3615
+ timezone: DEFAULT_SCHEDULE_TIMEZONE,
3616
+ paused: true,
3617
+ });
2778
3618
  const next = job.nextRun();
2779
3619
  if (next) {
2780
3620
  nextRunAt = (next instanceof Date ? next : new Date(next)).toISOString();
@@ -2899,7 +3739,6 @@ app.post<{ Body: Record<string, unknown> }>('/api/schedules', async (request, re
2899
3739
  .code(400)
2900
3740
  .send({ error: 'teamName、cronExpression、launchConfig.prompt 不能为空' });
2901
3741
  }
2902
-
2903
3742
  const created = await cc.createCronJob({
2904
3743
  project: teamName,
2905
3744
  session_key: sessionKey,
@@ -3237,25 +4076,23 @@ app.post<{ Body: { dirPath?: string } }>('/api/workspace/list', async (request)
3237
4076
 
3238
4077
  try {
3239
4078
  const entries = readdirSync(target, { withFileTypes: true });
3240
- const files = entries
3241
- .slice(0, 500)
3242
- .map((e) => {
3243
- const fullPath = path.join(target, e.name);
3244
- const isDirectory = e.isDirectory();
3245
- let size = 0;
3246
- try {
3247
- const stat = statSync(fullPath);
3248
- size = stat.size;
3249
- } catch {
3250
- /* ignore */
3251
- }
3252
- return {
3253
- name: e.name,
3254
- isDirectory,
3255
- size,
3256
- ext: isDirectory ? '' : path.extname(e.name).slice(1).toLowerCase(),
3257
- };
3258
- });
4079
+ const files = entries.slice(0, 500).map((e) => {
4080
+ const fullPath = path.join(target, e.name);
4081
+ const isDirectory = e.isDirectory();
4082
+ let size = 0;
4083
+ try {
4084
+ const stat = statSync(fullPath);
4085
+ size = stat.size;
4086
+ } catch {
4087
+ /* ignore */
4088
+ }
4089
+ return {
4090
+ name: e.name,
4091
+ isDirectory,
4092
+ size,
4093
+ ext: isDirectory ? '' : path.extname(e.name).slice(1).toLowerCase(),
4094
+ };
4095
+ });
3259
4096
  return { path: target, files, hasParent: target !== path.dirname(target) };
3260
4097
  } catch {
3261
4098
  return { path: target, files: [], hasParent: false, error: `无法访问目录: ${target}` };
@@ -3725,63 +4562,38 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/sessions', async (reques
3725
4562
  const workDir = team.workDir || team.bindProject || request.params.name;
3726
4563
  const localSessions = await localSessionScanner.scanSummaries(workDir, request.params.name);
3727
4564
 
3728
- // Attempt to merge cc-connect identity metadata (platform/chatName/userName/lastMessage)
3729
- 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[] = [];
3730
4568
  try {
3731
4569
  const bindProject = await resolveRouteCcProjectName(request.params.name);
3732
- const ccSessions = await cc.listSessions(bindProject);
3733
- for (const s of ccSessions) {
3734
- ccById.set(s.id, {
3735
- platform: s.platform,
3736
- userName: s.user_name ?? null,
3737
- chatName: s.chat_name ?? null,
3738
- lastMessage: s.last_message
3739
- ? { role: s.last_message.role, content: s.last_message.content, timestamp: s.last_message.timestamp }
3740
- : null,
3741
- });
3742
- }
3743
- } catch { /* cc-connect unavailable — local-only data */ }
4570
+ ccSessions = await cc.listSessions(bindProject);
4571
+ } catch {
4572
+ /* cc-connect unavailable — local-only data */
4573
+ }
3744
4574
 
3745
- return localSessions.map((s) => {
3746
- const ccMeta = ccById.get(s.id);
3747
- return {
3748
- id: s.id,
3749
- title: s.title || s.id,
3750
- projectId: request.params.name,
3751
- sessionKey: s.id,
3752
- platform: ccMeta?.platform ?? 'local',
3753
- userName: ccMeta?.userName ?? null,
3754
- chatName: ccMeta?.chatName ?? null,
3755
- active: s.active,
3756
- live: s.live,
3757
- historyCount: s.messageCount,
3758
- createdAt: s.createdAt,
3759
- updatedAt: s.updatedAt,
3760
- lastMessage: ccMeta?.lastMessage ?? null,
3761
- };
3762
- });
4575
+ return mergeLocalAndCcSessions(localSessions, ccSessions, request.params.name);
3763
4576
  } catch {
3764
4577
  return [];
3765
4578
  }
3766
4579
  });
3767
4580
 
3768
4581
  // GET session detail — read local JSONL file for session history with pagination
3769
- app.get<{ Params: { name: string; sessionId: string }; Querystring: { history_limit?: string; offset?: string } }>(
3770
- '/api/teams/:name/sessions/:sessionId',
3771
- async (request, reply) => {
3772
- const limit = request.query.history_limit
3773
- ? parseInt(request.query.history_limit, 10)
3774
- : 500;
3775
- const offset = request.query.offset
3776
- ? parseInt(request.query.offset, 10)
3777
- : 0;
3778
- const team = await svc.readTeamManifest(request.params.name);
3779
- const workDir = team.workDir || team.bindProject || request.params.name;
3780
- const detail = await localSessionScanner.readSessionDetail(workDir, request.params.sessionId, { offset, limit });
3781
- if (!detail) return reply.code(404).send({ error: 'Session not found' });
3782
- return detail;
3783
- }
3784
- );
4582
+ app.get<{
4583
+ Params: { name: string; sessionId: string };
4584
+ Querystring: { history_limit?: string; offset?: string };
4585
+ }>('/api/teams/:name/sessions/:sessionId', async (request, reply) => {
4586
+ const limit = request.query.history_limit ? parseInt(request.query.history_limit, 10) : 500;
4587
+ const offset = request.query.offset ? parseInt(request.query.offset, 10) : 0;
4588
+ const team = await svc.readTeamManifest(request.params.name);
4589
+ const workDir = team.workDir || team.bindProject || request.params.name;
4590
+ const detail = await localSessionScanner.readSessionDetail(workDir, request.params.sessionId, {
4591
+ offset,
4592
+ limit,
4593
+ });
4594
+ if (!detail) return reply.code(404).send({ error: 'Session not found' });
4595
+ return detail;
4596
+ });
3785
4597
 
3786
4598
  // DELETE session — 关闭 cc-connect live session,使其从运行中转为历史会话。
3787
4599
  app.delete<{ Params: { name: string; sessionId: string } }>(
@@ -3842,15 +4654,8 @@ app.post<{ Params: { name: string }; Body: { text?: string; message?: string } }
3842
4654
  try {
3843
4655
  const text = request.body?.text ?? request.body?.message ?? '';
3844
4656
  if (text) {
3845
- let targetProject = request.params.name;
3846
- try {
3847
- const manifest = await svc.readTeamManifest(request.params.name);
3848
- targetProject = manifest.bindProject || request.params.name;
3849
- } catch {
3850
- // request.params.name may already be a cc-connect project name.
3851
- }
3852
4657
  await sendHarnessMessageViaBridge({
3853
- teamName: targetProject,
4658
+ teamName: request.params.name,
3854
4659
  text,
3855
4660
  });
3856
4661
  }
@@ -3904,8 +4709,16 @@ app.get('/api/teams/tasks', async () => {
3904
4709
  // 团队任务子操作 — 全部委托给 svc.patchTask
3905
4710
  app.post<{ Params: { name: string; id: string } }>(
3906
4711
  '/api/teams/:name/tasks/:id/request-review',
3907
- async (request) => {
4712
+ async (request, reply) => {
3908
4713
  try {
4714
+ const tasks = await svc.readTasks(request.params.name);
4715
+ const existingTask = tasks.find((task) => task.id === request.params.id);
4716
+ if (existingTask?.status === 'doing') {
4717
+ return reply.code(409).send({
4718
+ ok: false,
4719
+ error: 'Agent 正在处理中,不能手动提交审核。请等待 agent 调用 complete_task。',
4720
+ });
4721
+ }
3909
4722
  const task = await svc.patchTask(request.params.name, request.params.id, { status: 'done' });
3910
4723
  return { ok: true, data: toTeamTask(task) };
3911
4724
  } catch {
@@ -3922,12 +4735,24 @@ app.patch<{ Params: { name: string; id: string }; Body: Record<string, unknown>
3922
4735
  );
3923
4736
  app.patch<{ Params: { name: string; id: string }; Body: { status?: string } }>(
3924
4737
  '/api/teams/:name/tasks/:id/status',
3925
- async (request) => {
4738
+ async (request, reply) => {
3926
4739
  try {
3927
4740
  const { status } = request.body ?? {};
4741
+ const nextStatus = status ? toTaskStatus(status) : undefined;
4742
+ const tasks = await svc.readTasks(request.params.name);
4743
+ const existingTask = tasks.find((task) => task.id === request.params.id);
4744
+ if (isManualInProgressExitBlocked(existingTask?.status, nextStatus)) {
4745
+ return reply.code(409).send({
4746
+ ok: false,
4747
+ error: 'Agent 正在处理中,不能手动完成或取消。请等待 agent 调用 complete_task。',
4748
+ });
4749
+ }
3928
4750
  const task = await svc.patchTask(request.params.name, request.params.id, {
3929
- status: status ? toTaskStatus(status) : undefined,
4751
+ status: nextStatus,
3930
4752
  });
4753
+ if (task.dispatchMeta && task.status === 'done') {
4754
+ await taskDispatch.onTaskCompleted(request.params.name, request.params.id).catch(() => {});
4755
+ }
3931
4756
  return toTeamTask(task);
3932
4757
  } catch {
3933
4758
  return { ok: true };
@@ -3942,9 +4767,6 @@ app.patch<{ Params: { name: string; id: string }; Body: { owner?: string } }>(
3942
4767
  const task = await svc.patchTask(request.params.name, request.params.id, {
3943
4768
  assignee: body.owner ?? null,
3944
4769
  });
3945
- if (task.assignee) {
3946
- svc.dispatchTask(request.params.name, task).catch(() => {});
3947
- }
3948
4770
  return toTeamTask(task);
3949
4771
  } catch {
3950
4772
  return { ok: true };
@@ -3970,9 +4792,16 @@ app.post<{ Params: { name: string; id: string } }>(
3970
4792
  '/api/teams/:name/tasks/:id/start',
3971
4793
  async (request) => {
3972
4794
  try {
4795
+ const existingTasks = await svc.readTasks(request.params.name);
4796
+ const existingTask = existingTasks.find((task) => task.id === request.params.id);
4797
+ if (existingTask?.dispatchMeta) {
4798
+ await taskDispatch.startDispatchedTask(request.params.name, request.params.id);
4799
+ return { notifiedOwner: true, crossTeamStarted: true };
4800
+ }
4801
+
3973
4802
  const task = await svc.patchTask(request.params.name, request.params.id, { status: 'doing' });
3974
4803
  if (task.assignee) {
3975
- svc.dispatchTask(request.params.name, task).catch(() => {});
4804
+ await svc.dispatchTask(request.params.name, task).catch(() => {});
3976
4805
  return { notifiedOwner: true };
3977
4806
  }
3978
4807
  return { notifiedOwner: false };
@@ -3985,9 +4814,16 @@ app.post<{ Params: { name: string; id: string } }>(
3985
4814
  '/api/teams/:name/tasks/:id/start-by-user',
3986
4815
  async (request) => {
3987
4816
  try {
4817
+ const existingTasks = await svc.readTasks(request.params.name);
4818
+ const existingTask = existingTasks.find((task) => task.id === request.params.id);
4819
+ if (existingTask?.dispatchMeta) {
4820
+ await taskDispatch.startDispatchedTask(request.params.name, request.params.id);
4821
+ return { notifiedOwner: true, crossTeamStarted: true };
4822
+ }
4823
+
3988
4824
  const task = await svc.patchTask(request.params.name, request.params.id, { status: 'doing' });
3989
4825
  if (task.assignee) {
3990
- svc.dispatchTask(request.params.name, task).catch(() => {});
4826
+ await svc.dispatchTask(request.params.name, task).catch(() => {});
3991
4827
  return { notifiedOwner: true };
3992
4828
  }
3993
4829
  return { notifiedOwner: false };
@@ -4000,6 +4836,14 @@ app.post<{ Params: { name: string; id: string } }>(
4000
4836
  '/api/teams/:name/tasks/:id/soft-delete',
4001
4837
  async (request, reply) => {
4002
4838
  try {
4839
+ const tasks = await svc.readTasks(request.params.name);
4840
+ const existingTask = tasks.find((task) => task.id === request.params.id);
4841
+ if (existingTask?.status === 'doing') {
4842
+ return reply.code(409).send({
4843
+ ok: false,
4844
+ error: 'Agent 正在处理中,不能手动删除任务。',
4845
+ });
4846
+ }
4003
4847
  await svc.patchTask(request.params.name, request.params.id, {
4004
4848
  status: 'done',
4005
4849
  result: '__deleted__',
@@ -4094,18 +4938,17 @@ async function applyTeamConfigUpdate(
4094
4938
  const providerRefs = Array.isArray(body.providerRefs)
4095
4939
  ? normalizeStringArray(body.providerRefs)
4096
4940
  : undefined;
4097
- const platformAllowFrom = body.platformAllowFrom
4098
- ? normalizePlatformAllowFrom(body.platformAllowFrom)
4099
- : undefined;
4100
- const platformAllowChat = body.platformAllowChat
4101
- ? normalizePlatformAllowFrom(body.platformAllowChat)
4102
- : undefined;
4941
+ const platformAllowFrom = normalizePlatformAllowUpdate(body.platformAllowFrom);
4942
+ const platformAllowChat = normalizePlatformAllowUpdate(body.platformAllowChat);
4103
4943
 
4104
- // Validate agent type CLI availability 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
+ }
4105
4948
  if (agentType && agentType !== 'claudecode') {
4106
4949
  try {
4107
- const { execSync } = await import('node:child_process');
4108
- execSync(`which ${agentType}`, { stdio: 'pipe', timeout: 5000 });
4950
+ const { execFileSync } = await import('node:child_process');
4951
+ execFileSync('which', [agentType], { stdio: 'pipe', timeout: 5000 });
4109
4952
  } catch {
4110
4953
  throw new Error(
4111
4954
  `${agentType} CLI 未安装,无法切换到 ${agentType} 模式。请先安装对应的 CLI 工具。`
@@ -4118,7 +4961,9 @@ async function applyTeamConfigUpdate(
4118
4961
  if (description) localPatch.description = description;
4119
4962
  if (color) localPatch.color = color;
4120
4963
  if (agentType) localPatch.harness = agentType;
4121
- if (workDir) localPatch.workDir = workDir;
4964
+ if (workDir) {
4965
+ localPatch.workDir = workDir;
4966
+ }
4122
4967
  if (permissionMode) localPatch.permissionMode = permissionMode;
4123
4968
  if (language) localPatch.language = language;
4124
4969
  if (managedSources) localPatch.managedSources = managedSources;
@@ -4168,6 +5013,7 @@ async function applyTeamConfigUpdate(
4168
5013
  } catch {
4169
5014
  bindProject = teamName;
4170
5015
  }
5016
+
4171
5017
  if (Object.keys(ccPatch).length > 0) {
4172
5018
  try {
4173
5019
  const updateResult = await cc.updateProject(
@@ -4175,17 +5021,25 @@ async function applyTeamConfigUpdate(
4175
5021
  ccPatch as Parameters<CcConnectClient['updateProject']>[1]
4176
5022
  );
4177
5023
  if (updateResult.restart_required) {
4178
- try { await cc.reload(); } catch { /* best effort */ }
5024
+ try {
5025
+ await cc.reload();
5026
+ } catch {
5027
+ /* best effort */
5028
+ }
4179
5029
  }
4180
5030
  } catch (err) {
4181
- ccSyncError = err instanceof Error ? err.message : String(err);
5031
+ if (!isCcProjectNotFoundError(err)) {
5032
+ ccSyncError = err instanceof Error ? err.message : String(err);
5033
+ }
4182
5034
  }
4183
5035
  }
4184
5036
  if (providerRefs !== undefined) {
4185
5037
  try {
4186
5038
  await cc.setProviderRefs(bindProject, providerRefs);
4187
5039
  } catch (err) {
4188
- ccSyncError = err instanceof Error ? err.message : String(err);
5040
+ if (!isCcProjectNotFoundError(err)) {
5041
+ ccSyncError = err instanceof Error ? err.message : String(err);
5042
+ }
4189
5043
  }
4190
5044
  }
4191
5045
 
@@ -4204,6 +5058,7 @@ async function applyTeamConfigUpdate(
4204
5058
  replyFooter: replyFooter ?? false,
4205
5059
  injectSender: injectSender ?? false,
4206
5060
  platformAllowFrom: platformAllowFrom ?? {},
5061
+ platformAllowChat: platformAllowChat ?? {},
4207
5062
  providerRefs: providerRefs ?? [],
4208
5063
  ccSyncError,
4209
5064
  };
@@ -4295,7 +5150,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/config', async (request,
4295
5150
  return {
4296
5151
  name,
4297
5152
  color,
4298
- projectPath: p.work_dir ?? '',
5153
+ projectPath: p.work_dir || '',
4299
5154
  description,
4300
5155
  agentType: p.agent_type,
4301
5156
  workDir: p.work_dir ?? '',
@@ -4319,6 +5174,7 @@ app.get<{ Params: { name: string } }>('/api/teams/:name/config', async (request,
4319
5174
  reply_footer: resolvedReplyFooter,
4320
5175
  inject_sender: resolvedInjectSender,
4321
5176
  platform_allow_from: resolvedPlatformAllowFrom,
5177
+ platform_allow_chat: resolvedPlatformAllowChat,
4322
5178
  },
4323
5179
  };
4324
5180
  } catch {
@@ -4412,6 +5268,7 @@ app.post<{
4412
5268
  { deadlineMinutes: 10, needsHumanReview: true }
4413
5269
  );
4414
5270
  broadcastSse('team-change', { type: 'inbox', teamName });
5271
+ broadcastSse('team-change', { type: 'task', teamName: targetTeam });
4415
5272
  broadcastSse('collab-change', {
4416
5273
  dispatchId: result.dispatchId,
4417
5274
  status: result.status,
@@ -4420,14 +5277,15 @@ app.post<{
4420
5277
  });
4421
5278
  return {
4422
5279
  ok: result.status !== 'failed',
4423
- deliveredToInbox: true,
5280
+ deliveredToInbox: false,
4424
5281
  messageId: sourceMsg.id,
4425
5282
  dispatchId: result.dispatchId,
4426
5283
  status: result.status,
4427
5284
  message: result.message,
4428
5285
  runtimeDelivery: {
4429
- attempted: true,
4430
- delivered: result.status !== 'failed',
5286
+ attempted: false,
5287
+ delivered: false,
5288
+ reason: 'waiting_for_target_start',
4431
5289
  },
4432
5290
  };
4433
5291
  } catch (err) {
@@ -4445,6 +5303,7 @@ app.post<{
4445
5303
  // 本地存储用户消息
4446
5304
  const userMsg = await svc
4447
5305
  .appendMessage(teamName, {
5306
+ id: msgId,
4448
5307
  from: 'user',
4449
5308
  to: teamName,
4450
5309
  role: 'user',
@@ -4456,16 +5315,37 @@ app.post<{
4456
5315
  // 广播 SSE 让前端触发消息刷新
4457
5316
  broadcastSse('team-change', { type: 'inbox', teamName });
4458
5317
 
4459
- const bridgeWasConnected = bridge.connected;
4460
- void sendHarnessMessageViaBridge({
4461
- teamName,
4462
- text,
4463
- sessionKey,
4464
- msgId,
4465
- }).catch((err) => {
4466
- request.log.warn({ err, teamName, sessionKey }, 'send-message bridge delivery failed');
4467
- broadcastSse('team-change', { type: 'inbox', teamName });
4468
- });
5318
+ // Member DM: dispatch to the local claude CLI directly (bypass cc-connect). One
5319
+ // subprocess per member, resumed across messages. The reply streams back via the
5320
+ // manager event listener and is persisted on the turn's `result` event. cc-connect's
5321
+ // bridge stays reserved for external IM (Feishu/WeChat).
5322
+ const member = typeof request.body?.member === 'string' ? request.body.member.trim() : '';
5323
+ const directSessionKey = `${teamName}:member:${member || 'lead'}`;
5324
+ const memberWorkDir = await resolveDirectCliWorkDir(teamName).catch(() => '');
5325
+ const dispatchedDirect = Boolean(memberWorkDir);
5326
+ if (dispatchedDirect) {
5327
+ void dispatchDirectCliMessage({
5328
+ teamName,
5329
+ sessionKey: directSessionKey,
5330
+ workDir: memberWorkDir,
5331
+ from: member || teamName,
5332
+ to: 'user',
5333
+ text,
5334
+ // The agent reply needs its OWN id — distinct from the user message's
5335
+ // `msgId`. Reusing `msgId` persisted the reply with the user message's id,
5336
+ // colliding in the inbox so the renderer's id-keyed dedup dropped it
5337
+ // (the team-3ond "回复的没了" bug).
5338
+ messageId: buildDirectReplyMessageId(directSessionKey),
5339
+ }).catch((err) => {
5340
+ request.log.warn(
5341
+ { err, teamName, sessionKey: directSessionKey },
5342
+ 'send-message direct-cli delivery failed'
5343
+ );
5344
+ broadcastSse('team-change', { type: 'inbox', teamName });
5345
+ });
5346
+ } else {
5347
+ request.log.warn({ teamName }, 'send-message direct-cli skipped: no work_dir resolved');
5348
+ }
4469
5349
 
4470
5350
  return {
4471
5351
  ok: true,
@@ -4473,7 +5353,7 @@ app.post<{
4473
5353
  messageId: userMsg?.id ?? msgId,
4474
5354
  runtimeDelivery: {
4475
5355
  attempted: true,
4476
- delivered: bridgeWasConnected,
5356
+ delivered: dispatchedDirect,
4477
5357
  },
4478
5358
  };
4479
5359
  });
@@ -4485,8 +5365,16 @@ app.post<{
4485
5365
  // requestReview: 前端调用 /tasks/:id/review,服务端原路由是 /tasks/:id/request-review
4486
5366
  app.post<{ Params: { name: string; id: string } }>(
4487
5367
  '/api/teams/:name/tasks/:id/review',
4488
- async (request) => {
5368
+ async (request, reply) => {
4489
5369
  try {
5370
+ const tasks = await svc.readTasks(request.params.name);
5371
+ const existingTask = tasks.find((task) => task.id === request.params.id);
5372
+ if (existingTask?.status === 'doing') {
5373
+ return reply.code(409).send({
5374
+ ok: false,
5375
+ error: 'Agent 正在处理中,不能手动提交审核。请等待 agent 调用 complete_task。',
5376
+ });
5377
+ }
4490
5378
  const task = await svc.patchTask(request.params.name, request.params.id, { status: 'done' });
4491
5379
  return { ok: true, data: toTeamTask(task) };
4492
5380
  } catch {
@@ -4606,6 +5494,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
4606
5494
  let inputTokens = 0;
4607
5495
  let outputTokens = 0;
4608
5496
  let cacheReadTokens = 0;
5497
+ let cacheCreationTokens = 0;
5498
+ let totalTokens = 0;
4609
5499
  let messageCount = 0;
4610
5500
  let totalDurationMs = 0;
4611
5501
 
@@ -4616,6 +5506,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
4616
5506
  inputTokens += s.inputTokens;
4617
5507
  outputTokens += s.outputTokens;
4618
5508
  cacheReadTokens += s.cacheReadTokens;
5509
+ cacheCreationTokens += s.cacheCreationTokens;
5510
+ totalTokens += s.totalTokens;
4619
5511
  messageCount += s.messageCount;
4620
5512
 
4621
5513
  if (s.startTime && (!earliestStart || s.startTime < earliestStart)) {
@@ -4649,6 +5541,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
4649
5541
  inputTokens,
4650
5542
  outputTokens,
4651
5543
  cacheReadTokens,
5544
+ cacheCreationTokens,
5545
+ totalTokens,
4652
5546
  costUsd: 0,
4653
5547
  tasksCompleted,
4654
5548
  messageCount,
@@ -4666,6 +5560,8 @@ app.get<{ Params: { name: string; memberName: string } }>(
4666
5560
  inputTokens: 0,
4667
5561
  outputTokens: 0,
4668
5562
  cacheReadTokens: 0,
5563
+ cacheCreationTokens: 0,
5564
+ totalTokens: 0,
4669
5565
  costUsd: 0,
4670
5566
  tasksCompleted: 0,
4671
5567
  messageCount: 0,
@@ -4677,14 +5573,79 @@ app.get<{ Params: { name: string; memberName: string } }>(
4677
5573
  }
4678
5574
  );
4679
5575
 
4680
- // tool-approval stubs
4681
- app.post<{ Params: { name: string } }>('/api/teams/:name/tool-approval/respond', async () => ({
4682
- ok: true,
4683
- }));
4684
- app.post<{ Params: { name: string } }>('/api/teams/:name/tool-approval/settings', async () => ({
4685
- ok: true,
4686
- }));
4687
- 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
+ );
4688
5649
 
4689
5650
  // validate-cli-args
4690
5651
  app.post('/api/teams/validate-cli-args', async () => ({ valid: true, args: [], errors: [] }));
@@ -4743,6 +5704,110 @@ app.get<{ Querystring: { excludeTeam?: string } }>('/api/cross-team/targets', as
4743
5704
  }));
4744
5705
  });
4745
5706
 
5707
+ async function listDiscoverableWorkers(): Promise<DiscoverableWorker[]> {
5708
+ const teams = await taskDispatch.discoverTeams();
5709
+ return teams
5710
+ .filter((team) => team.slug !== SYSTEM_MANAGER_TEAM_NAME && team.location === 'local')
5711
+ .map(discoverableTeamToWorker)
5712
+ .sort((a, b) => a.name.localeCompare(b.name));
5713
+ }
5714
+
5715
+ app.get('/api/workers', async () => {
5716
+ return { workers: await listDiscoverableWorkers() };
5717
+ });
5718
+
5719
+ app.post<{
5720
+ Params: { workerId: string };
5721
+ Body: {
5722
+ fromTeam?: string;
5723
+ text?: unknown;
5724
+ summary?: unknown;
5725
+ sessionName?: unknown;
5726
+ reuse?: unknown;
5727
+ sessionKey?: unknown;
5728
+ };
5729
+ }>('/api/workers/:workerId/invoke', async (request, reply) => {
5730
+ try {
5731
+ const workerId = request.params.workerId.trim();
5732
+ const resolvedWorkerId = await resolveTeamSlugForMention(workerId);
5733
+ if (!resolvedWorkerId || resolvedWorkerId === SYSTEM_MANAGER_TEAM_NAME) {
5734
+ return reply.code(404).send({ error: `Unknown worker: ${workerId}` });
5735
+ }
5736
+
5737
+ const workers = await listDiscoverableWorkers();
5738
+ const worker = workers.find((entry) => entry.workerId === resolvedWorkerId);
5739
+ if (!worker) return reply.code(404).send({ error: `Unknown worker: ${workerId}` });
5740
+
5741
+ const message = typeof request.body?.text === 'string' ? request.body.text.trim() : '';
5742
+ if (!message) return reply.code(400).send({ error: 'text is required' });
5743
+
5744
+ const requestedSessionName =
5745
+ typeof request.body?.sessionName === 'string' ? request.body.sessionName.trim() : '';
5746
+ const summary = typeof request.body?.summary === 'string' ? request.body.summary.trim() : '';
5747
+ const sessionName =
5748
+ requestedSessionName ||
5749
+ summary ||
5750
+ `Admin Invoke ${new Date().toISOString().replace(/[:.]/g, '-')}`;
5751
+ const reuse = request.body?.reuse !== false;
5752
+ const fromTeam = typeof request.body?.fromTeam === 'string' ? request.body.fromTeam.trim() : '';
5753
+ const fromSessionKey =
5754
+ typeof request.body?.sessionKey === 'string' && request.body.sessionKey.trim().length > 0
5755
+ ? request.body.sessionKey.trim()
5756
+ : buildFallbackSessionKey(fromTeam || SYSTEM_MANAGER_TEAM_NAME);
5757
+
5758
+ const { bindProject } = await ensureLoopSessionProjectReady(resolvedWorkerId);
5759
+ const sessionKey = `${buildFallbackSessionKey(resolvedWorkerId)}:${Date.now().toString(36)}`;
5760
+ const sessions = reuse ? await cc.listSessions(bindProject).catch(() => []) : [];
5761
+ let session = reuse
5762
+ ? sessions.find((item) => item.name === sessionName && (item.live || item.active))
5763
+ : undefined;
5764
+ const reused = Boolean(session);
5765
+ if (!session) {
5766
+ const created = await cc.createSession(bindProject, sessionName, sessionKey);
5767
+ session = {
5768
+ id: created.id,
5769
+ name: created.name || sessionName,
5770
+ session_key: created.session_key || sessionKey,
5771
+ agent_session_id: created.agent_session_id,
5772
+ agent_type: created.agent_type,
5773
+ active: created.active,
5774
+ live: created.live,
5775
+ history_count: created.history_count,
5776
+ created_at: created.created_at,
5777
+ updated_at: created.updated_at,
5778
+ last_message: null,
5779
+ platform: created.platform,
5780
+ };
5781
+ }
5782
+
5783
+ await sendHarnessMessageViaBridge({
5784
+ teamName: resolvedWorkerId,
5785
+ text: message,
5786
+ sessionKey: session.session_key,
5787
+ });
5788
+ if (fromTeam) {
5789
+ await svc.appendMessage(fromTeam, {
5790
+ from: `${fromTeam}.user`,
5791
+ to: resolvedWorkerId,
5792
+ role: 'user',
5793
+ content: `@${resolvedWorkerId} ${message}`,
5794
+ meta: { source: CROSS_TEAM_SENT_SOURCE, sessionKey: fromSessionKey, summary },
5795
+ });
5796
+ broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
5797
+ }
5798
+ broadcastSse('team-change', { type: 'inbox', teamName: resolvedWorkerId });
5799
+ return {
5800
+ ok: true,
5801
+ worker,
5802
+ session: mapCcSessionListItem(session, resolvedWorkerId),
5803
+ reused,
5804
+ messageSent: true,
5805
+ };
5806
+ } catch (err) {
5807
+ return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
5808
+ }
5809
+ });
5810
+
4746
5811
  app.get<{ Params: { name: string } }>('/api/cross-team/outbox/:name', async (request) => {
4747
5812
  const teamSlug = request.params.name;
4748
5813
  const tasks = await svc.readTasks(teamSlug);
@@ -4897,11 +5962,6 @@ app.post<{
4897
5962
  typeof sessionKey === 'string' && sessionKey.trim().length > 0
4898
5963
  ? sessionKey.trim()
4899
5964
  : buildFallbackSessionKey(fromTeam);
4900
- const toSessionKey = buildFallbackSessionKey(resolvedToTeam);
4901
- const sentText = formatCrossTeamText(`${fromTeam}.${sender}`, depth, trimmedText, {
4902
- conversationId: threadId,
4903
- replyToConversationId,
4904
- });
4905
5965
  const meta = {
4906
5966
  taskRefs,
4907
5967
  actionMode,
@@ -4919,53 +5979,35 @@ app.post<{
4919
5979
  meta: { ...meta, source: CROSS_TEAM_SENT_SOURCE, sessionKey: fromSessionKey },
4920
5980
  });
4921
5981
 
4922
- await svc.appendMessage(resolvedToTeam, {
4923
- from: `${fromTeam}.${sender}`,
4924
- to: resolvedToTeam,
4925
- role: 'user',
4926
- content: sentText,
4927
- meta: {
4928
- ...meta,
4929
- source: CROSS_TEAM_SOURCE,
4930
- relayOfMessageId: outgoing.id,
4931
- sessionKey: toSessionKey,
4932
- },
4933
- });
4934
-
4935
- const existingTasks = await svc.readTasks(resolvedToTeam).catch(() => []);
4936
- const existingTask = existingTasks.find((task) => task.dispatchMeta?.dispatchId === threadId);
4937
- if (!existingTask) {
4938
- const now = new Date().toISOString();
4939
- await svc.createTask(resolvedToTeam, {
4940
- title: summary || trimmedText.split(/\r?\n/, 1)[0]?.slice(0, 120) || '跨团队 @ 消息',
5982
+ // Do not write the relayed message into the target inbox here. Cross-team
5983
+ // transfer must first create a target TODO/review surface; the target inbox or
5984
+ // runtime should only receive content after the user explicitly starts it.
5985
+ const dispatchResult = await taskDispatch.dispatchTask(
5986
+ fromTeam,
5987
+ {
5988
+ subject: summary || trimmedText.split(/\r?\n/, 1)[0]?.slice(0, 120) || '跨团队 @ 消息',
4941
5989
  description: trimmedText,
4942
- status: 'todo',
4943
- dispatchMeta: {
4944
- dispatchId: threadId,
4945
- originTeam: fromTeam,
4946
- targetTeam: resolvedToTeam,
4947
- status: 'pending_accept',
4948
- dispatchedAt: now,
4949
- receivedAt: now,
4950
- },
4951
- });
5990
+ prompt: trimmedText,
5991
+ },
5992
+ resolvedToTeam,
5993
+ { dispatchId: threadId, needsHumanReview: needsHumanReview ?? true }
5994
+ );
5995
+ if (dispatchResult.status === 'failed') {
5996
+ return { ok: false, error: dispatchResult.message };
4952
5997
  }
4953
5998
 
4954
5999
  broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
4955
- broadcastSse('team-change', { type: 'inbox', teamName: resolvedToTeam });
4956
6000
  broadcastSse('team-change', { type: 'task', teamName: resolvedToTeam });
4957
6001
 
4958
- void sendHarnessMessageViaBridge({
4959
- teamName: resolvedToTeam,
4960
- text: sentText,
4961
- }).catch((err) => {
4962
- request.log.warn({ err }, 'cross-team runtime delivery failed after persistence');
4963
- });
4964
-
4965
6002
  return {
4966
6003
  messageId: outgoing.id,
4967
- deliveredToInbox: true,
6004
+ deliveredToInbox: false,
4968
6005
  deduplicated: false,
6006
+ runtimeDelivery: {
6007
+ attempted: false,
6008
+ delivered: false,
6009
+ reason: 'waiting_for_target_start',
6010
+ },
4969
6011
  };
4970
6012
  }
4971
6013
 
@@ -4984,21 +6026,6 @@ app.post<{
4984
6026
  });
4985
6027
  broadcastSse('team-change', { type: 'inbox', teamName: fromTeam });
4986
6028
 
4987
- // Check collaboration toggle
4988
- try {
4989
- const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
4990
- const raw = await fs.readFile(configPath, 'utf-8');
4991
- const settings = JSON.parse(raw);
4992
- if (!settings.taskBus?.collaboration) {
4993
- return {
4994
- ok: false,
4995
- error: 'Distributed collaboration is not enabled. Enable it in Settings → Task Bus.',
4996
- };
4997
- }
4998
- } catch {
4999
- return { ok: false, error: 'Could not read task bus configuration.' };
5000
- }
5001
-
5002
6029
  const result = await taskDispatch.dispatchTask(
5003
6030
  fromTeam ?? 'unknown',
5004
6031
  { subject, description, prompt },
@@ -5011,15 +6038,7 @@ app.post<{
5011
6038
  const ok = result.status !== 'failed';
5012
6039
  if (ok) {
5013
6040
  broadcastSse('team-change', { type: 'inbox', teamName: resolvedToTeam });
5014
- void sendHarnessMessageViaBridge({
5015
- teamName: resolvedToTeam,
5016
- text: `[跨团队任务] ${fromTeam} 派发了任务:${subject}${description ? `\n\n${description}` : ''}`,
5017
- }).catch((err) => {
5018
- request.log.warn(
5019
- { err, fromTeam, resolvedToTeam },
5020
- 'cross-team task runtime delivery failed'
5021
- );
5022
- });
6041
+ broadcastSse('team-change', { type: 'task', teamName: resolvedToTeam });
5023
6042
  }
5024
6043
  return {
5025
6044
  ok,
@@ -5138,18 +6157,171 @@ app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
5138
6157
  return { ok: true, connected: false, message: 'Task bus disabled' };
5139
6158
  });
5140
6159
 
6160
+ type TelemetryProjectRow = {
6161
+ cwd: string;
6162
+ displayName?: string;
6163
+ teamSlug?: string;
6164
+ bindProject?: string;
6165
+ sessions: number;
6166
+ messages: number;
6167
+ tokensIn: number;
6168
+ tokensOut: number;
6169
+ tokensTotal: number;
6170
+ };
6171
+
6172
+ type TelemetryStatusShape = {
6173
+ connected: boolean;
6174
+ lastScan: string | null;
6175
+ sessions: number;
6176
+ messages: number;
6177
+ tokensIn: number;
6178
+ tokensOut: number;
6179
+ cacheRead: number;
6180
+ cacheCreation: number;
6181
+ totalTokens: number;
6182
+ activeDays: number;
6183
+ hourly: number[];
6184
+ projects: TelemetryProjectRow[];
6185
+ workSecondsByDay: Record<string, number>;
6186
+ };
6187
+
6188
+ async function readTaskBusSettings(): Promise<TaskBusConfig> {
6189
+ const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
6190
+ let settings: Record<string, unknown> = {};
6191
+ try {
6192
+ const raw = await fs.readFile(configPath, 'utf-8');
6193
+ settings = JSON.parse(raw);
6194
+ } catch {
6195
+ // no settings
6196
+ }
6197
+ return (settings.taskBus ?? {}) as TaskBusConfig;
6198
+ }
6199
+
6200
+ async function enrichTelemetryProjectNames<T extends { projects: TelemetryProjectRow[] }>(
6201
+ status: T
6202
+ ): Promise<T> {
6203
+ const teams = await svc.listTeams().catch(() => []);
6204
+ const byWorkDir = new Map<string, TeamManifest>();
6205
+ const byBindProject = new Map<string, TeamManifest>();
6206
+ for (const team of teams) {
6207
+ if (team.slug === SYSTEM_MANAGER_TEAM_NAME) continue;
6208
+ const workDir = (team.workDir || '').trim();
6209
+ if (workDir) byWorkDir.set(path.resolve(workDir), team);
6210
+ if (team.bindProject) byBindProject.set(team.bindProject, team);
6211
+ byBindProject.set(team.slug, team);
6212
+ }
6213
+
6214
+ return {
6215
+ ...status,
6216
+ projects: status.projects.map((project) => {
6217
+ const cwd = (project.cwd || '').trim();
6218
+ const team =
6219
+ (cwd ? byWorkDir.get(path.resolve(cwd)) : undefined) ??
6220
+ byBindProject.get(cwd) ??
6221
+ byBindProject.get(path.basename(cwd));
6222
+ if (!team) return project;
6223
+ return {
6224
+ ...project,
6225
+ displayName: team.displayName || team.slug,
6226
+ teamSlug: team.slug,
6227
+ bindProject: team.bindProject,
6228
+ };
6229
+ }),
6230
+ };
6231
+ }
6232
+
6233
+ function telemetryEmptyStatus(): TelemetryStatusShape {
6234
+ return {
6235
+ connected: false,
6236
+ lastScan: null,
6237
+ sessions: 0,
6238
+ messages: 0,
6239
+ tokensIn: 0,
6240
+ tokensOut: 0,
6241
+ cacheRead: 0,
6242
+ cacheCreation: 0,
6243
+ totalTokens: 0,
6244
+ activeDays: 0,
6245
+ hourly: [],
6246
+ projects: [],
6247
+ workSecondsByDay: {},
6248
+ };
6249
+ }
6250
+
6251
+ function csvCell(value: unknown): string {
6252
+ const text = String(value ?? '');
6253
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
6254
+ }
6255
+
6256
+ function buildUsageTelemetryExport(status: TelemetryStatusShape, format: 'csv' | 'json') {
6257
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
6258
+ if (format === 'json') {
6259
+ return {
6260
+ filename: `hermit-loop-usage-${stamp}.json`,
6261
+ mimeType: 'application/json;charset=utf-8',
6262
+ content: JSON.stringify(status, null, 2),
6263
+ };
6264
+ }
6265
+
6266
+ const rows = [
6267
+ [
6268
+ 'section',
6269
+ 'name',
6270
+ 'sessions',
6271
+ 'messages',
6272
+ 'tokensIn',
6273
+ 'tokensOut',
6274
+ 'cacheRead',
6275
+ 'cacheCreation',
6276
+ 'totalTokens',
6277
+ 'activeDays',
6278
+ 'durationSeconds',
6279
+ 'cwd',
6280
+ ],
6281
+ [
6282
+ 'summary',
6283
+ '累计 Loop 数据',
6284
+ status.sessions,
6285
+ status.messages,
6286
+ status.tokensIn,
6287
+ status.tokensOut,
6288
+ status.cacheRead,
6289
+ status.cacheCreation,
6290
+ status.totalTokens,
6291
+ status.activeDays,
6292
+ '',
6293
+ '',
6294
+ ],
6295
+ ...Object.entries(status.workSecondsByDay)
6296
+ .sort(([a], [b]) => a.localeCompare(b))
6297
+ .map(([day, seconds]) => ['day', day, '', '', '', '', '', '', '', '', seconds, '']),
6298
+ ...status.projects.map((project) => [
6299
+ 'project',
6300
+ project.displayName || path.basename(project.cwd) || project.cwd,
6301
+ project.sessions,
6302
+ project.messages,
6303
+ project.tokensIn,
6304
+ project.tokensOut,
6305
+ '',
6306
+ '',
6307
+ project.tokensTotal,
6308
+ '',
6309
+ '',
6310
+ project.cwd,
6311
+ ]),
6312
+ ];
6313
+
6314
+ return {
6315
+ filename: `hermit-loop-usage-${stamp}.csv`,
6316
+ mimeType: 'text/csv;charset=utf-8',
6317
+ content: rows.map((row) => row.map(csvCell).join(',')).join('\n'),
6318
+ };
6319
+ }
6320
+
5141
6321
  // POST /api/telemetry/scan → trigger manual scan
5142
6322
  app.post('/api/telemetry/scan', async (request, reply) => {
5143
6323
  try {
5144
- const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
5145
- let settings: Record<string, unknown> = {};
5146
- try {
5147
- const raw = await fs.readFile(configPath, 'utf-8');
5148
- settings = JSON.parse(raw);
5149
- } catch {
5150
- // no settings
5151
- }
5152
- const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
6324
+ const taskBus = await readTaskBusSettings();
5153
6325
  if (!taskBus.telemetry?.enabled) {
5154
6326
  return reply.code(400).send({ error: 'Telemetry is not enabled' });
5155
6327
  }
@@ -5157,7 +6329,7 @@ app.post('/api/telemetry/scan', async (request, reply) => {
5157
6329
  if (!result) {
5158
6330
  return reply.code(503).send({ error: 'Telemetry scan failed' });
5159
6331
  }
5160
- return {
6332
+ return await enrichTelemetryProjectNames({
5161
6333
  ok: true,
5162
6334
  connected: taskBus.telemetry.uploadEnabled === true,
5163
6335
  lastScan: new Date().toISOString(),
@@ -5167,16 +6339,35 @@ app.post('/api/telemetry/scan', async (request, reply) => {
5167
6339
  tokensOut: result.aggregate.tokens.output,
5168
6340
  cacheRead: result.aggregate.tokens.cacheRead,
5169
6341
  cacheCreation: result.aggregate.tokens.cacheCreation,
6342
+ totalTokens: result.aggregate.tokens.total,
5170
6343
  activeDays: result.aggregate.activeDays,
5171
6344
  hourly: result.aggregate.hourly,
5172
6345
  projects: result.aggregate.projects,
5173
6346
  workSecondsByDay: result.aggregate.workSecondsByDay,
5174
- };
6347
+ });
5175
6348
  } catch (err) {
5176
6349
  return reply.code(500).send({ error: String(err) });
5177
6350
  }
5178
6351
  });
5179
6352
 
6353
+ // GET /api/telemetry/export → export Loop usage telemetry summary/projects
6354
+ app.get<{ Querystring: { format?: 'csv' | 'json' | string } }>(
6355
+ '/api/telemetry/export',
6356
+ async (request, reply) => {
6357
+ try {
6358
+ const format = request.query.format === 'json' ? 'json' : 'csv';
6359
+ const taskBus = await readTaskBusSettings();
6360
+ const redisCfg = taskBus.enabled ? taskBus.redis : undefined;
6361
+ const status = await enrichTelemetryProjectNames(
6362
+ (await getTelemetryStatus(redisCfg)) ?? telemetryEmptyStatus()
6363
+ );
6364
+ return buildUsageTelemetryExport(status, format);
6365
+ } catch (err) {
6366
+ return reply.code(500).send({ error: String(err) });
6367
+ }
6368
+ }
6369
+ );
6370
+
5180
6371
  // GET /api/telemetry/conversations → local Feishu/Lark conversation telemetry
5181
6372
  app.get<{
5182
6373
  Querystring: {
@@ -5274,48 +6465,12 @@ app.get<{
5274
6465
  // GET /api/telemetry/status → current telemetry status (full stats)
5275
6466
  app.get('/api/telemetry/status', async (request, reply) => {
5276
6467
  try {
5277
- const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
5278
- let settings: Record<string, unknown> = {};
5279
- try {
5280
- const raw = await fs.readFile(configPath, 'utf-8');
5281
- settings = JSON.parse(raw);
5282
- } catch {
5283
- // no settings
5284
- }
5285
- const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
6468
+ const taskBus = await readTaskBusSettings();
5286
6469
  const redisCfg = taskBus.enabled ? taskBus.redis : undefined;
5287
6470
  const status = await getTelemetryStatus(redisCfg);
5288
- return (
5289
- status ?? {
5290
- connected: false,
5291
- lastScan: null,
5292
- sessions: 0,
5293
- messages: 0,
5294
- tokensIn: 0,
5295
- tokensOut: 0,
5296
- cacheRead: 0,
5297
- cacheCreation: 0,
5298
- activeDays: 0,
5299
- hourly: [],
5300
- projects: [],
5301
- workSecondsByDay: {},
5302
- }
5303
- );
6471
+ return await enrichTelemetryProjectNames(status ?? telemetryEmptyStatus());
5304
6472
  } catch {
5305
- return {
5306
- connected: false,
5307
- lastScan: null,
5308
- sessions: 0,
5309
- messages: 0,
5310
- tokensIn: 0,
5311
- tokensOut: 0,
5312
- cacheRead: 0,
5313
- cacheCreation: 0,
5314
- activeDays: 0,
5315
- hourly: [],
5316
- projects: [],
5317
- workSecondsByDay: {},
5318
- };
6473
+ return telemetryEmptyStatus();
5319
6474
  }
5320
6475
  });
5321
6476
 
@@ -5476,6 +6631,22 @@ app.post('/api/extensions/mcp/library/import', async (request) => {
5476
6631
  return ext.mcpLibraryImport((request.body ?? {}) as any);
5477
6632
  });
5478
6633
 
6634
+ app.get('/api/extensions/capability-packs', async () => {
6635
+ return ext.capabilityPacksList();
6636
+ });
6637
+
6638
+ app.post('/api/extensions/capability-packs/import', async (request) => {
6639
+ return ext.capabilityPacksImport((request.body ?? {}) as any);
6640
+ });
6641
+
6642
+ app.post('/api/extensions/capability-packs/export', async (request) => {
6643
+ return ext.capabilityPacksExport((request.body ?? {}) as any);
6644
+ });
6645
+
6646
+ app.post('/api/extensions/capability-packs/command-prompt', async (request) => {
6647
+ return ext.capabilityPacksCommandPrompt((request.body ?? {}) as any);
6648
+ });
6649
+
5479
6650
  app.get('/api/extensions/skills', async (request) => {
5480
6651
  const projectPath = (request.query as Record<string, string>).projectPath;
5481
6652
  const result = await ext.skillsList(projectPath);
@@ -5688,6 +6859,7 @@ try {
5688
6859
  // graceful shutdown
5689
6860
  const shutdown = async () => {
5690
6861
  try {
6862
+ directCliManager.shutdown();
5691
6863
  bridge.dispose?.();
5692
6864
  await app.close();
5693
6865
  process.exit(0);
@@ -5697,3 +6869,6 @@ const shutdown = async () => {
5697
6869
  };
5698
6870
  process.on('SIGINT', shutdown);
5699
6871
  process.on('SIGTERM', shutdown);
6872
+ // Sync backstop: reap direct-CLI subprocesses on any exit path that skips the async
6873
+ // shutdown (e.g. process killed without a delivered signal). child.kill() is synchronous.
6874
+ process.on('exit', () => directCliManager.shutdown());