@yancyyu/openhermit 1.6.28 → 1.6.30

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 (177) hide show
  1. package/dist-renderer/assets/ProjectEditorOverlay-DsQt4FHy.js +52 -0
  2. package/dist-renderer/assets/{TeamGraphOverlay-Ba5njic5.js → TeamGraphOverlay-BjZC53xf.js} +1 -1
  3. package/dist-renderer/assets/{_basePickBy-BvnK-OC1.js → _basePickBy-CrWocIjq.js} +1 -1
  4. package/dist-renderer/assets/{_baseUniq-DmFYXx9G.js → _baseUniq-B6d8ysWi.js} +1 -1
  5. package/dist-renderer/assets/{arc-DX4ZQFY4.js → arc-DAIYCFP8.js} +1 -1
  6. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-DfYr3vEN.js → architectureDiagram-VXUJARFQ-B3UudXJh.js} +1 -1
  7. package/dist-renderer/assets/{blockDiagram-VD42YOAC-DuXdVeWn.js → blockDiagram-VD42YOAC-DbptKQ4W.js} +1 -1
  8. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-Bw2nixXe.js → c4Diagram-YG6GDRKO-C4WQuZpV.js} +1 -1
  9. package/dist-renderer/assets/channel-DbjZvWii.js +1 -0
  10. package/dist-renderer/assets/{chunk-4BX2VUAB-DLiNGQoE.js → chunk-4BX2VUAB-Dp7fVpI_.js} +1 -1
  11. package/dist-renderer/assets/{chunk-55IACEB6-B1L_8VIF.js → chunk-55IACEB6-B8KGfbAy.js} +1 -1
  12. package/dist-renderer/assets/{chunk-B4BG7PRW-DaZMWKGk.js → chunk-B4BG7PRW-BG1oJrjA.js} +1 -1
  13. package/dist-renderer/assets/{chunk-DI55MBZ5-ku-dflJG.js → chunk-DI55MBZ5-DRmxNjht.js} +1 -1
  14. package/dist-renderer/assets/{chunk-FMBD7UC4-DV-mF1dP.js → chunk-FMBD7UC4-D6VLvy16.js} +1 -1
  15. package/dist-renderer/assets/{chunk-QN33PNHL-ByGcDFQ0.js → chunk-QN33PNHL-DZou1667.js} +1 -1
  16. package/dist-renderer/assets/{chunk-QZHKN3VN-7dv-Min8.js → chunk-QZHKN3VN-CghmasSh.js} +1 -1
  17. package/dist-renderer/assets/{chunk-TZMSLE5B-WdXL5fTu.js → chunk-TZMSLE5B-B7apcMPK.js} +1 -1
  18. package/dist-renderer/assets/classDiagram-2ON5EDUG-D_FGxxsl.js +1 -0
  19. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-D_FGxxsl.js +1 -0
  20. package/dist-renderer/assets/clone-CJ1kxO2J.js +1 -0
  21. package/dist-renderer/assets/{cose-bilkent-S5V4N54A-CNcsvqPl.js → cose-bilkent-S5V4N54A-05e5uQDp.js} +1 -1
  22. package/dist-renderer/assets/{dagre-6UL2VRFP-DBNx4qqx.js → dagre-6UL2VRFP-B06bRykF.js} +1 -1
  23. package/dist-renderer/assets/{diagram-PSM6KHXK-BfVlT6sT.js → diagram-PSM6KHXK-CY7VYQ7c.js} +1 -1
  24. package/dist-renderer/assets/{diagram-QEK2KX5R-HvVjs0K6.js → diagram-QEK2KX5R-BjKEH7dD.js} +1 -1
  25. package/dist-renderer/assets/{diagram-S2PKOQOG-DYb_KnWS.js → diagram-S2PKOQOG-Bf4ELS1_.js} +1 -1
  26. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-Ba-IgI5G.js → erDiagram-Q2GNP2WA-DJ753_L9.js} +1 -1
  27. package/dist-renderer/assets/{flowDiagram-NV44I4VS-2iDN8Kpj.js → flowDiagram-NV44I4VS-B71S-lC-.js} +1 -1
  28. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-Byjf8Fa3.js → ganttDiagram-JELNMOA3-C_U42mSZ.js} +1 -1
  29. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js → gitGraphDiagram-V2S2FVAM-DKUJU4Ns.js} +1 -1
  30. package/dist-renderer/assets/{graph-Enirf-f8.js → graph-DY3qbzqj.js} +1 -1
  31. package/dist-renderer/assets/{index-DY1zqsb6.js → index-BlOrAXp3.js} +551 -537
  32. package/dist-renderer/assets/{index-AjxP_rE_.js → index-Bs27J5gB.js} +1 -1
  33. package/dist-renderer/assets/{index-CtlzGepK.js → index-C8B_nKOF.js} +1 -1
  34. package/dist-renderer/assets/index-CmZPUEhS.css +1 -0
  35. package/dist-renderer/assets/{index-COZPUWJW.js → index-DLKyDr4T.js} +1 -1
  36. package/dist-renderer/assets/{index-DdhqolqE.js → index-Dhsk3_DD.js} +1 -1
  37. package/dist-renderer/assets/{index-ChR1D6ZF.js → index-GpUvV2xs.js} +1 -1
  38. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-D6uicwz1.js → infoDiagram-HS3SLOUP-BNs0y3IG.js} +1 -1
  39. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-DqwZsXlQ.js → journeyDiagram-XKPGCS4Q-CqPnw4UV.js} +1 -1
  40. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-fCDVhVUm.js → kanban-definition-3W4ZIXB7-SLlzcUJ2.js} +1 -1
  41. package/dist-renderer/assets/{layout-CPFgj98r.js → layout-BZLlNmbr.js} +1 -1
  42. package/dist-renderer/assets/{linear-CYiQ7Y3M.js → linear-qz6v45xy.js} +1 -1
  43. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-D31dS2KE.js → mindmap-definition-VGOIOE7T-B1-kmEWV.js} +1 -1
  44. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BOsCJfds.js → pieDiagram-ADFJNKIX-B8a02iNx.js} +1 -1
  45. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-CYTVQCfr.js → quadrantDiagram-AYHSOK5B-BKv1Xfou.js} +1 -1
  46. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-CODCFpkt.js → requirementDiagram-UZGBJVZJ-B3DUpZi2.js} +1 -1
  47. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js → sankeyDiagram-TZEHDZUN-DmPzuTsy.js} +1 -1
  48. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-CmS9TxhW.js → sequenceDiagram-WL72ISMW-Bo7RelRb.js} +1 -1
  49. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-o9k-ns3q.js → stateDiagram-FKZM4ZOC-1epX98gV.js} +1 -1
  50. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-CxHMyEt1.js → stateDiagram-v2-4FDKWEC3-03Ym9PTr.js} +1 -1
  51. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-B6T3zrde.js → timeline-definition-IT6M3QCI-r6isC62H.js} +1 -1
  52. package/dist-renderer/assets/{treemap-GDKQZRPO-CVd5GNDw.js → treemap-GDKQZRPO-CGKpOUF2.js} +1 -1
  53. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-CleBrdqc.js → xychartDiagram-PRI3JC2R-t4-rwdAw.js} +1 -1
  54. package/dist-renderer/index.html +2 -2
  55. package/package.json +4 -1
  56. package/src/main/ipc/extensions.ts +353 -0
  57. package/src/main/server.ts +907 -184
  58. package/src/main/services/extensions/ExtensionFacadeService.ts +135 -0
  59. package/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts +190 -0
  60. package/src/main/services/extensions/catalog/McpCatalogAggregator.ts +150 -0
  61. package/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts +381 -0
  62. package/src/main/services/extensions/catalog/PluginCatalogService.ts +392 -0
  63. package/src/main/services/extensions/credentials/CredentialService.ts +343 -0
  64. package/src/main/services/extensions/install/McpInstallService.ts +407 -0
  65. package/src/main/services/extensions/install/PluginInstallService.ts +198 -0
  66. package/src/main/services/extensions/runtime/ClaudeCodeAdapter.ts +199 -0
  67. package/src/main/services/extensions/runtime/CodexAdapter.ts +100 -0
  68. package/src/main/services/extensions/runtime/CursorAdapter.ts +154 -0
  69. package/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +172 -0
  70. package/src/main/services/extensions/runtime/GeminiAdapter.ts +91 -0
  71. package/src/main/services/extensions/runtime/HarnessInstallAdapter.ts +49 -0
  72. package/src/main/services/extensions/runtime/McpConfigStateReader.ts +209 -0
  73. package/src/main/services/extensions/runtime/OpenCodeAdapter.ts +91 -0
  74. package/src/main/services/extensions/runtime/adapterRegistry.ts +54 -0
  75. package/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +214 -0
  76. package/src/main/services/extensions/runtime/mcpRuntimeJson.ts +45 -0
  77. package/src/main/services/extensions/skills/SkillImportService.ts +155 -0
  78. package/src/main/services/extensions/skills/SkillMetadataParser.ts +323 -0
  79. package/src/main/services/extensions/skills/SkillPlanService.ts +411 -0
  80. package/src/main/services/extensions/skills/SkillReviewService.ts +73 -0
  81. package/src/main/services/extensions/skills/SkillRootsResolver.ts +49 -0
  82. package/src/main/services/extensions/skills/SkillScaffoldService.ts +89 -0
  83. package/src/main/services/extensions/skills/SkillScanner.ts +117 -0
  84. package/src/main/services/extensions/skills/SkillValidator.ts +69 -0
  85. package/src/main/services/extensions/skills/SkillsCatalogService.ts +92 -0
  86. package/src/main/services/extensions/skills/SkillsMutationService.ts +146 -0
  87. package/src/main/services/extensions/skills/SkillsWatcherService.ts +134 -0
  88. package/src/main/services/extensions/state/McpInstallationStateService.ts +42 -0
  89. package/src/main/services/extensions/state/PluginInstallationStateService.ts +281 -0
  90. package/src/main/services/identity/AgentTeamsIdentityStore.ts +218 -0
  91. package/src/main/services/runtime/providerAwareCliEnv.ts +60 -0
  92. package/src/main/services/session-intelligence/UsageTelemetryService.ts +33 -18
  93. package/src/main/services/team/ClaudeBinaryResolver.ts +469 -0
  94. package/src/main/services/team/ClaudeDoctorProbe.ts +0 -0
  95. package/src/main/services/team/cliFlavor.ts +54 -0
  96. package/src/main/services/teams-mvp/CollaborationBoardService.ts +310 -0
  97. package/src/main/services/teams-mvp/TaskDispatchService.ts +883 -95
  98. package/src/main/services/teams-mvp/TeamProvisioningService.ts +58 -19
  99. package/src/main/services/teams-mvp/TeamWorkspaceService.ts +25 -2
  100. package/src/main/services/teams-mvp/index.ts +3 -0
  101. package/src/main/utils/atomicWrite.ts +72 -0
  102. package/src/main/utils/childProcess.ts +554 -0
  103. package/src/main/utils/cliEnv.ts +54 -0
  104. package/src/main/utils/cliPathMerge.ts +97 -0
  105. package/src/main/utils/pathDecoder.ts +664 -0
  106. package/src/main/utils/pathValidation.ts +432 -0
  107. package/src/main/utils/shellEnv.ts +331 -0
  108. package/src/renderer/App.tsx +5 -0
  109. package/src/renderer/api/httpClient.ts +128 -0
  110. package/src/renderer/components/extensions/ExtensionStoreView.tsx +59 -34
  111. package/src/renderer/components/extensions/ExtensionsSubTabTrigger.tsx +1 -1
  112. package/src/renderer/components/extensions/common/ExtensionToast.tsx +141 -0
  113. package/src/renderer/components/extensions/common/HarnessSelector.tsx +71 -0
  114. package/src/renderer/components/extensions/env/EnvVarPanel.tsx +335 -0
  115. package/src/renderer/components/extensions/env/ProjectEnvPanel.tsx +239 -0
  116. package/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +14 -223
  117. package/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +11 -0
  118. package/src/renderer/components/extensions/mcp/McpServersPanel.tsx +51 -1
  119. package/src/renderer/components/extensions/skills/SkillsPanel.tsx +1 -126
  120. package/src/renderer/components/layout/PaneContent.tsx +2 -0
  121. package/src/renderer/components/layout/SortableTab.tsx +1 -0
  122. package/src/renderer/components/layout/TabBarActions.tsx +12 -12
  123. package/src/renderer/components/schedules/SchedulesView.tsx +54 -22
  124. package/src/renderer/components/settings/sections/AdvancedSection.tsx +1 -1
  125. package/src/renderer/components/settings/sections/HarnessSection.tsx +2 -6
  126. package/src/renderer/components/settings/sections/TaskBusSection.tsx +144 -84
  127. package/src/renderer/components/sidebar/SidebarSessions.tsx +23 -0
  128. package/src/renderer/components/sidebar/WorkspaceBrowser.tsx +1 -7
  129. package/src/renderer/components/tasks/TasksView.tsx +343 -0
  130. package/src/renderer/components/team/HarnessSelect.tsx +71 -0
  131. package/src/renderer/components/team/TeamDetailView.tsx +55 -98
  132. package/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +21 -12
  133. package/src/renderer/components/team/dialogs/EditTeamDialog.tsx +8 -13
  134. package/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +1 -1
  135. package/src/renderer/components/team/editor/EditorContextMenu.tsx +8 -23
  136. package/src/renderer/components/team/editor/EditorFileTree.tsx +0 -4
  137. package/src/renderer/components/team/editor/EditorSelectionMenu.tsx +1 -8
  138. package/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +0 -10
  139. package/src/renderer/components/team/kanban/KanbanBoard.tsx +31 -65
  140. package/src/renderer/components/team/members/MemberDetailDialog.tsx +8 -33
  141. package/src/renderer/components/team/messages/MessageComposer.tsx +39 -3
  142. package/src/renderer/components/team/messages/MessagesPanel.tsx +100 -26
  143. package/src/renderer/components/team/messages/StatusBlock.tsx +2 -24
  144. package/src/renderer/components/team/schedule/ScheduleEmptyState.tsx +1 -1
  145. package/src/renderer/components/terminal/TerminalPanel.tsx +156 -0
  146. package/src/renderer/components/ui/MentionableTextarea.tsx +0 -1
  147. package/src/renderer/hooks/useExtensionsTabState.ts +2 -2
  148. package/src/renderer/store/slices/extensionsSlice.ts +42 -107
  149. package/src/renderer/store/slices/scheduleSlice.ts +21 -0
  150. package/src/renderer/store/slices/teamSlice.ts +67 -25
  151. package/src/renderer/types/tabs.ts +1 -0
  152. package/src/shared/types/api.ts +58 -0
  153. package/src/shared/types/extensions/index.ts +1 -0
  154. package/src/shared/types/extensions/mcp.ts +2 -0
  155. package/src/shared/types/extensions/plugin.ts +2 -1
  156. package/src/shared/types/extensions/skill.ts +7 -0
  157. package/src/shared/types/team.ts +104 -1
  158. package/src/shared/utils/providerExtensionCapabilities.ts +1 -1
  159. package/dist-renderer/assets/ProjectEditorOverlay-A4DZTvSy.js +0 -57
  160. package/dist-renderer/assets/channel-Pre42N5O.js +0 -1
  161. package/dist-renderer/assets/classDiagram-2ON5EDUG-CdJsTJsj.js +0 -1
  162. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-CdJsTJsj.js +0 -1
  163. package/dist-renderer/assets/clone-BjQBiNfj.js +0 -1
  164. package/dist-renderer/assets/index-BIOJremZ.css +0 -1
  165. package/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +0 -30
  166. package/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +0 -27
  167. package/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +0 -91
  168. package/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +0 -326
  169. package/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +0 -43
  170. package/src/features/recent-projects/main/index.ts +0 -3
  171. package/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts +0 -34
  172. package/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +0 -116
  173. package/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts +0 -20
  174. package/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts +0 -10
  175. package/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +0 -143
  176. package/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +0 -282
  177. package/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +0 -280
@@ -1,11 +1,15 @@
1
1
  import type {
2
+ AgentCapability,
3
+ CollabTask,
2
4
  DiscoverableTeam,
3
5
  DispatchMeta,
4
6
  TaskBusConfig,
5
7
  TaskDispatchPayload,
8
+ TaskHandshakeResponse,
6
9
  TaskStatusUpdate,
7
10
  } from '@shared/types/team';
8
11
  import type { TeamWorkspaceService, TeamManifest } from './TeamWorkspaceService';
12
+ import type { CollaborationBoardService } from './CollaborationBoardService';
9
13
  import type Redis from 'ioredis';
10
14
 
11
15
  const DISPATCH_RULES_DEFAULT = `When to dispatch a task to another team:
@@ -19,6 +23,14 @@ Do NOT dispatch:
19
23
  - Task can be completed with available tools
20
24
  - Task is a small change (< estimated 5 min)`;
21
25
 
26
+ interface PendingRequest {
27
+ payload: TaskDispatchPayload;
28
+ msgId: string;
29
+ groupName: string;
30
+ teamSlug: string;
31
+ localTaskId?: string;
32
+ }
33
+
22
34
  export interface DispatchResult {
23
35
  dispatchId: string;
24
36
  status: DispatchMeta['status'];
@@ -28,15 +40,23 @@ export interface DispatchResult {
28
40
 
29
41
  export class TaskDispatchService {
30
42
  private workspace: TeamWorkspaceService;
43
+ private collabBoard: CollaborationBoardService;
31
44
  private config: TaskBusConfig | null = null;
32
45
  private redis: Redis | null = null;
33
46
  private redisSub: Redis | null = null;
34
47
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
35
48
  private consumerTimers: ReturnType<typeof setInterval>[] = [];
49
+ private responseConsumerTimers: ReturnType<typeof setInterval>[] = [];
50
+ private consumerTeamSlugs = new Set<string>();
51
+ private responseConsumerTeamSlugs = new Set<string>();
36
52
  private disposed = false;
53
+ private pendingRequests: Map<string, PendingRequest> = new Map();
54
+ /** Callback fired when collab task state changes (for SSE broadcast). */
55
+ onCollabChange?: (dispatchId: string, status: string, fromTeam: string, toTeam: string) => void;
37
56
 
38
- constructor(workspace: TeamWorkspaceService) {
57
+ constructor(workspace: TeamWorkspaceService, collabBoard: CollaborationBoardService) {
39
58
  this.workspace = workspace;
59
+ this.collabBoard = collabBoard;
40
60
  }
41
61
 
42
62
  get dispatchRulesText(): string {
@@ -44,9 +64,13 @@ export class TaskDispatchService {
44
64
  }
45
65
 
46
66
  async start(config?: TaskBusConfig): Promise<void> {
67
+ this.disposed = false;
47
68
  this.config = config ?? null;
48
69
  if (config?.enabled && config.redis) {
49
70
  await this.connectRedis();
71
+ if (!this.redis) {
72
+ throw new Error('Redis connection failed: PING did not succeed');
73
+ }
50
74
  }
51
75
  }
52
76
 
@@ -54,6 +78,7 @@ export class TaskDispatchService {
54
78
  this.disposed = true;
55
79
  this.stopHeartbeat();
56
80
  this.stopConsumers();
81
+ this.stopResponseConsumers();
57
82
  this.redis?.disconnect();
58
83
  this.redisSub?.disconnect();
59
84
  this.redis = null;
@@ -63,9 +88,12 @@ export class TaskDispatchService {
63
88
  // ── Agent-facing ──────────────────────────────────────────────
64
89
 
65
90
  async listTeams(): Promise<DiscoverableTeam[]> {
91
+ return this.discoverTeams();
92
+ }
93
+
94
+ async discoverTeams(): Promise<DiscoverableTeam[]> {
66
95
  const teams: DiscoverableTeam[] = [];
67
96
 
68
- // Local teams
69
97
  const localTeams = await this.workspace.listTeams();
70
98
  for (const team of localTeams) {
71
99
  teams.push({
@@ -74,26 +102,44 @@ export class TaskDispatchService {
74
102
  location: 'local',
75
103
  status: 'online',
76
104
  collaboration: team.collaboration !== false,
105
+ description: team.description,
106
+ harness: team.harness,
107
+ capabilities: this.inferCapabilities(team),
77
108
  });
78
109
  }
79
110
 
80
- // Remote teams (via Redis)
81
111
  if (this.redis) {
82
112
  try {
83
113
  const now = Date.now();
84
- const staleThreshold = 90_000; // 90s
114
+ const staleThreshold = 90_000;
85
115
  const entries = await this.redis.zrange('task:teams', 0, -1, 'WITHSCORES');
86
116
  const localSlugs = new Set(teams.map((t) => t.slug));
87
117
  for (let i = 0; i < entries.length; i += 2) {
88
118
  const slug = entries[i] as string;
89
119
  const ts = Number(entries[i + 1]);
90
120
  if (localSlugs.has(slug)) continue;
121
+ const isOnline = now - ts < staleThreshold;
122
+
123
+ let info: Record<string, string> | null = null;
124
+ try {
125
+ info = (await this.redis!.hgetall(`task:team:info:${slug}`)) as Record<string, string>;
126
+ } catch {
127
+ /* degraded */
128
+ }
129
+
130
+ const capabilities = info?.capabilities
131
+ ? (JSON.parse(info.capabilities) as AgentCapability[])
132
+ : undefined;
133
+
91
134
  teams.push({
92
135
  slug,
93
- displayName: slug,
136
+ displayName: info?.displayName ?? slug,
94
137
  location: 'remote',
95
- status: now - ts < staleThreshold ? 'online' : 'offline',
96
- collaboration: true,
138
+ status: isOnline ? 'online' : 'offline',
139
+ collaboration: info?.collaboration !== 'false',
140
+ description: info?.description || undefined,
141
+ harness: info?.harness || undefined,
142
+ capabilities,
97
143
  });
98
144
  }
99
145
  } catch {
@@ -107,7 +153,8 @@ export class TaskDispatchService {
107
153
  async dispatchTask(
108
154
  fromTeam: string,
109
155
  task: { subject: string; description?: string; prompt?: string },
110
- targetTeam: string
156
+ targetTeam: string,
157
+ opts?: { deadlineMinutes?: number; needsHumanReview?: boolean }
111
158
  ): Promise<DispatchResult> {
112
159
  if (fromTeam === targetTeam) {
113
160
  return {
@@ -119,35 +166,476 @@ export class TaskDispatchService {
119
166
  }
120
167
 
121
168
  const dispatchId = crypto.randomUUID();
169
+ const now = new Date();
170
+ const deadline = opts?.deadlineMinutes
171
+ ? new Date(now.getTime() + opts.deadlineMinutes * 60_000).toISOString()
172
+ : undefined;
173
+
122
174
  const dispatchMeta: DispatchMeta = {
123
175
  dispatchId,
124
176
  originTeam: fromTeam,
125
177
  targetTeam,
126
- status: 'dispatched',
127
- dispatchedAt: new Date().toISOString(),
178
+ status: 'pending_accept',
179
+ dispatchedAt: now.toISOString(),
180
+ deadline,
128
181
  };
129
182
 
130
- // Route: local or remote
131
- const isLocal = await this.isLocalTeam(targetTeam);
132
- if (isLocal) {
133
- await this.handleLocalDispatch(dispatchMeta, task);
134
- } else if (this.redis) {
135
- await this.handleRemoteDispatch(dispatchMeta, task);
136
- } else {
183
+ // Add to collaboration board before external delivery. Even failed dispatches
184
+ // must remain visible in the canonical task projection for diagnosis/retry.
185
+ const fromTeamManifest = await this.safeReadManifest(fromTeam);
186
+ const toTeamManifest = await this.safeReadManifest(targetTeam);
187
+ const collabTask: CollabTask = {
188
+ id: dispatchId,
189
+ dispatchId,
190
+ subject: task.subject,
191
+ description: task.description,
192
+ fromTeam,
193
+ fromTeamDisplay: fromTeamManifest?.displayName ?? fromTeam,
194
+ toTeam: targetTeam,
195
+ toTeamDisplay: toTeamManifest?.displayName ?? targetTeam,
196
+ status: 'pending_accept',
197
+ deadline,
198
+ needsHumanReview: opts?.needsHumanReview ?? false,
199
+ revisionCount: 0,
200
+ createdAt: now.toISOString(),
201
+ updatedAt: now.toISOString(),
202
+ };
203
+ this.collabBoard.addTask(collabTask);
204
+
205
+ // Route: all dispatches require Redis
206
+ if (!this.redis) {
207
+ const failedTask = this.collabBoard.transition({
208
+ dispatchId,
209
+ expected: 'pending_accept',
210
+ next: 'failed',
211
+ actor: { type: 'system', id: 'task-dispatch' },
212
+ eventType: 'task_failed',
213
+ payload: { reason: 'Redis not configured' },
214
+ extra: { reason: 'Redis not configured — cross-team dispatch requires task bus.' },
215
+ });
216
+ this.emitCollabChange(dispatchId, failedTask.status, fromTeam, targetTeam);
137
217
  return {
138
218
  dispatchId,
139
219
  status: 'failed',
140
220
  targetTeam,
141
- message: 'Redis not configured — remote dispatch unavailable.',
221
+ message: 'Redis not configured — cross-team dispatch requires task bus.',
142
222
  };
143
223
  }
144
224
 
225
+ try {
226
+ await this.handleRedisDispatch(dispatchMeta, task, opts?.needsHumanReview);
227
+ } catch (err) {
228
+ this.pendingRequests.delete(dispatchId);
229
+ const reason = err instanceof Error ? err.message : 'Unknown Redis dispatch failure';
230
+ const failedTask = this.collabBoard.transition({
231
+ dispatchId,
232
+ expected: 'pending_accept',
233
+ next: 'failed',
234
+ actor: { type: 'system', id: 'task-dispatch' },
235
+ eventType: 'task_failed',
236
+ payload: { reason },
237
+ extra: { reason },
238
+ });
239
+ this.emitCollabChange(dispatchId, failedTask.status, fromTeam, targetTeam);
240
+ return {
241
+ dispatchId,
242
+ status: 'failed',
243
+ targetTeam,
244
+ message: `Task dispatch failed: ${reason}`,
245
+ };
246
+ }
247
+
248
+ this.emitCollabChange(dispatchId, 'pending_accept', fromTeam, targetTeam);
249
+
145
250
  return {
146
251
  dispatchId,
147
- status: 'dispatched',
252
+ status: 'pending_accept',
148
253
  targetTeam,
149
- message: `Task dispatched to ${targetTeam}`,
254
+ message: `Task dispatched to ${targetTeam}, awaiting acceptance.`,
255
+ };
256
+ }
257
+
258
+ async acceptTask(teamSlug: string, dispatchId: string): Promise<{ taskId: string }> {
259
+ const pending = this.pendingRequests.get(dispatchId);
260
+ if (!pending) {
261
+ throw new Error(`No pending request found for dispatchId: ${dispatchId}`);
262
+ }
263
+
264
+ const { payload, msgId, groupName, localTaskId } = pending;
265
+
266
+ const remoteTaskId = localTaskId ?? payload.dispatchId;
267
+
268
+ // Send accept response
269
+ const response: TaskHandshakeResponse = {
270
+ dispatchId: payload.dispatchId,
271
+ type: 'task_accept',
272
+ fromTeam: teamSlug,
273
+ toTeam: payload.originTeam,
274
+ remoteTaskId,
275
+ acceptedAt: new Date().toISOString(),
276
+ };
277
+
278
+ const isLocalOrigin = await this.isLocalTeam(payload.originTeam);
279
+ if (isLocalOrigin) {
280
+ await this.handleLocalResponse(response);
281
+ } else if (this.redis) {
282
+ await this.redis
283
+ .xadd(`task:response:${payload.originTeam}`, '*', 'payload', JSON.stringify(response))
284
+ .catch((err: Error) => {
285
+ console.error('[TaskDispatchService] accept xadd failed:', err.message);
286
+ });
287
+ }
288
+
289
+ if (this.redis) {
290
+ await this.redis.xack(`task:dispatch:${teamSlug}`, groupName, msgId).catch(() => {});
291
+ }
292
+
293
+ this.pendingRequests.delete(dispatchId);
294
+
295
+ // Update collab board
296
+ const acceptedAt = new Date().toISOString();
297
+ this.collabBoard.transition({
298
+ dispatchId: payload.dispatchId,
299
+ expected: 'pending_accept',
300
+ next: 'accepted',
301
+ actor: { type: 'team', id: teamSlug },
302
+ eventType: 'task_accepted',
303
+ payload: { remoteTaskId },
304
+ extra: { acceptedAt },
305
+ });
306
+ this.emitCollabChange(payload.dispatchId, 'accepted', payload.originTeam, payload.targetTeam);
307
+
308
+ // Fixed flow: notify receiving agent to start executing
309
+ try {
310
+ await this.workspace.appendMessage(teamSlug, {
311
+ from: 'system',
312
+ to: 'team',
313
+ role: 'agent',
314
+ content: `[跨团队任务已确认] "${payload.task.subject}" — 来自 ${payload.originTeam} 的任务已被人工确认接单,请开始执行。任务描述:${payload.task.description ?? '无'}`,
315
+ meta: {
316
+ source: 'cross_team_accepted',
317
+ dispatchId: payload.dispatchId,
318
+ originTeam: payload.originTeam,
319
+ taskId: remoteTaskId,
320
+ },
321
+ });
322
+ } catch {}
323
+
324
+ // Fixed flow: notify originating agent that task was accepted
325
+ try {
326
+ await this.workspace.appendMessage(payload.originTeam, {
327
+ from: 'system',
328
+ to: 'team',
329
+ role: 'agent',
330
+ content: `[跨团队任务已接单] "${payload.task.subject}" — ${teamSlug} 已确认接单,正在执行中。`,
331
+ meta: {
332
+ source: 'cross_team_accepted_notify',
333
+ dispatchId: payload.dispatchId,
334
+ targetTeam: teamSlug,
335
+ },
336
+ });
337
+ } catch {}
338
+
339
+ // Feishu notification
340
+ this.sendFeishuNotification(
341
+ `跨团队任务已接单:${payload.originTeam} → ${teamSlug}\n${payload.task.subject}\n状态:执行中`
342
+ );
343
+
344
+ return { taskId: remoteTaskId };
345
+ }
346
+
347
+ async rejectTask(teamSlug: string, dispatchId: string, reason?: string): Promise<void> {
348
+ const pending = this.pendingRequests.get(dispatchId);
349
+ if (!pending) {
350
+ throw new Error(`No pending request found for dispatchId: ${dispatchId}`);
351
+ }
352
+
353
+ const { payload, msgId, groupName } = pending;
354
+
355
+ const response: TaskHandshakeResponse = {
356
+ dispatchId: payload.dispatchId,
357
+ type: 'task_reject',
358
+ fromTeam: teamSlug,
359
+ toTeam: payload.originTeam,
360
+ reason,
361
+ rejectedAt: new Date().toISOString(),
150
362
  };
363
+
364
+ const isLocalOrigin = await this.isLocalTeam(payload.originTeam);
365
+ if (isLocalOrigin) {
366
+ await this.handleLocalResponse(response);
367
+ } else if (this.redis) {
368
+ await this.redis
369
+ .xadd(`task:response:${payload.originTeam}`, '*', 'payload', JSON.stringify(response))
370
+ .catch((err: Error) => {
371
+ console.error('[TaskDispatchService] reject xadd failed:', err.message);
372
+ });
373
+ }
374
+
375
+ if (this.redis) {
376
+ await this.redis.xack(`task:dispatch:${teamSlug}`, groupName, msgId).catch(() => {});
377
+ }
378
+
379
+ this.pendingRequests.delete(dispatchId);
380
+
381
+ // Update collab board
382
+ this.collabBoard.transition({
383
+ dispatchId: payload.dispatchId,
384
+ expected: 'pending_accept',
385
+ next: 'rejected',
386
+ actor: { type: 'team', id: teamSlug },
387
+ eventType: 'task_rejected',
388
+ payload: { reason },
389
+ extra: { reason, rejectedAt: response.rejectedAt },
390
+ });
391
+ this.emitCollabChange(payload.dispatchId, 'rejected', payload.originTeam, payload.targetTeam);
392
+
393
+ // Fixed flow: notify originating agent that task was rejected
394
+ try {
395
+ await this.workspace.appendMessage(payload.originTeam, {
396
+ from: 'system',
397
+ to: 'team',
398
+ role: 'agent',
399
+ content: `[跨团队任务被拒绝] "${payload.task.subject}" — ${teamSlug} 拒绝了此任务。原因:${reason ?? '未说明'}`,
400
+ meta: {
401
+ source: 'cross_team_rejected',
402
+ dispatchId: payload.dispatchId,
403
+ targetTeam: teamSlug,
404
+ rejectReason: reason,
405
+ },
406
+ });
407
+ } catch {}
408
+
409
+ // Feishu notification
410
+ this.sendFeishuNotification(
411
+ `跨团队任务被拒绝:${payload.originTeam} → ${teamSlug}\n${payload.task.subject}\n原因:${reason ?? '未说明'}`
412
+ );
413
+ }
414
+
415
+ // ── Deliver / Approve / Revision ────────────────────────────────
416
+
417
+ async deliverTask(
418
+ teamSlug: string,
419
+ dispatchId: string,
420
+ result: string
421
+ ): Promise<{ ok: boolean }> {
422
+ const collabTask = this.collabBoard.getTask(dispatchId);
423
+ if (!collabTask) {
424
+ throw new Error(`No collab task found for dispatchId: ${dispatchId}`);
425
+ }
426
+
427
+ const deliveredAt = new Date().toISOString();
428
+
429
+ // Send deliver response to origin team
430
+ const response: TaskHandshakeResponse = {
431
+ dispatchId,
432
+ type: 'task_deliver',
433
+ fromTeam: teamSlug,
434
+ toTeam: collabTask.fromTeam,
435
+ result,
436
+ deliveredAt,
437
+ };
438
+
439
+ const isLocalOrigin = await this.isLocalTeam(collabTask.fromTeam);
440
+ if (isLocalOrigin) {
441
+ await this.handleLocalResponse(response);
442
+ } else if (this.redis) {
443
+ await this.redis
444
+ .xadd(`task:response:${collabTask.fromTeam}`, '*', 'payload', JSON.stringify(response))
445
+ .catch((err: Error) => {
446
+ console.error('[TaskDispatchService] deliver xadd failed:', err.message);
447
+ });
448
+ }
449
+
450
+ // Update local collab board
451
+ const deliveredTask = this.collabBoard.transition({
452
+ dispatchId,
453
+ expected: ['accepted', 'revision'],
454
+ next: 'delivered',
455
+ actor: { type: 'team', id: teamSlug },
456
+ eventType: 'task_delivered',
457
+ payload: { summary: result.slice(0, 1000) },
458
+ extra: { result, deliveredAt },
459
+ });
460
+ this.emitCollabChange(dispatchId, 'delivered', deliveredTask.fromTeam, deliveredTask.toTeam);
461
+
462
+ // Notify origin agent: task is ready for review
463
+ try {
464
+ await this.workspace.appendMessage(collabTask.fromTeam, {
465
+ from: 'system',
466
+ to: 'team',
467
+ role: 'agent',
468
+ content: `[跨团队任务待审核] "${collabTask.subject}" — ${teamSlug} 已完成任务并提交交付结果,请审核。结果:${result}`,
469
+ meta: {
470
+ source: 'cross_team_delivered',
471
+ dispatchId,
472
+ targetTeam: teamSlug,
473
+ result,
474
+ },
475
+ });
476
+ } catch {}
477
+
478
+ this.sendFeishuNotification(
479
+ `跨团队任务待审核:${collabTask.fromTeam} ← ${teamSlug}\n${collabTask.subject}\n状态:待审核`
480
+ );
481
+
482
+ return { ok: true };
483
+ }
484
+
485
+ async approveTask(teamSlug: string, dispatchId: string): Promise<{ ok: boolean }> {
486
+ const collabTask = this.collabBoard.getTask(dispatchId);
487
+ if (!collabTask) {
488
+ throw new Error(`No collab task found for dispatchId: ${dispatchId}`);
489
+ }
490
+
491
+ const approvedAt = new Date().toISOString();
492
+
493
+ // Send approve response to target team
494
+ const response: TaskHandshakeResponse = {
495
+ dispatchId,
496
+ type: 'task_approve',
497
+ fromTeam: teamSlug,
498
+ toTeam: collabTask.toTeam,
499
+ approvedAt,
500
+ };
501
+
502
+ const isLocalTarget = await this.isLocalTeam(collabTask.toTeam);
503
+ if (isLocalTarget) {
504
+ await this.handleLocalResponse(response);
505
+ } else if (this.redis) {
506
+ await this.redis
507
+ .xadd(`task:response:${collabTask.toTeam}`, '*', 'payload', JSON.stringify(response))
508
+ .catch((err: Error) => {
509
+ console.error('[TaskDispatchService] approve xadd failed:', err.message);
510
+ });
511
+ }
512
+
513
+ // Update collab board
514
+ const approvedTask = this.collabBoard.transition({
515
+ dispatchId,
516
+ expected: 'delivered',
517
+ next: 'approved',
518
+ actor: { type: 'team', id: teamSlug },
519
+ eventType: 'task_approved',
520
+ payload: { approvedAt },
521
+ extra: { approvedAt },
522
+ });
523
+ this.emitCollabChange(dispatchId, 'approved', approvedTask.fromTeam, approvedTask.toTeam);
524
+
525
+ // Notify origin agent: task is fully complete, you can continue
526
+ try {
527
+ await this.workspace.appendMessage(collabTask.fromTeam, {
528
+ from: 'system',
529
+ to: 'team',
530
+ role: 'agent',
531
+ content: `[跨团队任务已完成] "${collabTask.subject}" — ${collabTask.toTeam} 的交付已通过审核,此跨团队任务结束。`,
532
+ meta: {
533
+ source: 'cross_team_approved',
534
+ dispatchId,
535
+ targetTeam: collabTask.toTeam,
536
+ },
537
+ });
538
+ } catch {}
539
+
540
+ // Notify target agent: approved
541
+ try {
542
+ await this.workspace.appendMessage(collabTask.toTeam, {
543
+ from: 'system',
544
+ to: 'team',
545
+ role: 'agent',
546
+ content: `[跨团队任务审核通过] "${collabTask.subject}" — ${teamSlug} 已通过审核,任务完成。`,
547
+ meta: {
548
+ source: 'cross_team_approved_target',
549
+ dispatchId,
550
+ originTeam: teamSlug,
551
+ },
552
+ });
553
+ } catch {}
554
+
555
+ this.sendFeishuNotification(
556
+ `跨团队任务完成:${collabTask.fromTeam} ← ${collabTask.toTeam}\n${collabTask.subject}\n状态:已完成`
557
+ );
558
+
559
+ return { ok: true };
560
+ }
561
+
562
+ async rejectResult(
563
+ teamSlug: string,
564
+ dispatchId: string,
565
+ feedback: string
566
+ ): Promise<{ ok: boolean }> {
567
+ const collabTask = this.collabBoard.getTask(dispatchId);
568
+ if (!collabTask) {
569
+ throw new Error(`No collab task found for dispatchId: ${dispatchId}`);
570
+ }
571
+
572
+ const newRevisionCount = collabTask.revisionCount + 1;
573
+
574
+ // Send revision response to target team
575
+ const response: TaskHandshakeResponse = {
576
+ dispatchId,
577
+ type: 'task_revision',
578
+ fromTeam: teamSlug,
579
+ toTeam: collabTask.toTeam,
580
+ feedback,
581
+ };
582
+
583
+ const isLocalTarget = await this.isLocalTeam(collabTask.toTeam);
584
+ if (isLocalTarget) {
585
+ await this.handleLocalResponse(response);
586
+ } else if (this.redis) {
587
+ await this.redis
588
+ .xadd(`task:response:${collabTask.toTeam}`, '*', 'payload', JSON.stringify(response))
589
+ .catch((err: Error) => {
590
+ console.error('[TaskDispatchService] revision xadd failed:', err.message);
591
+ });
592
+ }
593
+
594
+ // Update collab board
595
+ const revisionTask = this.collabBoard.transition({
596
+ dispatchId,
597
+ expected: 'delivered',
598
+ next: 'revision',
599
+ actor: { type: 'team', id: teamSlug },
600
+ eventType: 'revision_requested',
601
+ payload: {
602
+ feedback,
603
+ previousResult: collabTask.result,
604
+ revisionCount: newRevisionCount,
605
+ },
606
+ extra: {
607
+ feedback,
608
+ revisionCount: newRevisionCount,
609
+ },
610
+ });
611
+ this.emitCollabChange(dispatchId, 'revision', revisionTask.fromTeam, revisionTask.toTeam);
612
+
613
+ return { ok: true };
614
+ }
615
+
616
+ /** Get the collaboration board. */
617
+ getCollabBoard() {
618
+ return this.collabBoard.getBoard();
619
+ }
620
+
621
+ /** Get a single collab task. */
622
+ getCollabTask(dispatchId: string) {
623
+ return this.collabBoard.getTask(dispatchId);
624
+ }
625
+
626
+ /** Get event log for a collab task. */
627
+ getCollabTaskEvents(dispatchId: string) {
628
+ return this.collabBoard.getEvents(dispatchId);
629
+ }
630
+
631
+ listPendingRequests(teamSlug: string): TaskDispatchPayload[] {
632
+ const results: TaskDispatchPayload[] = [];
633
+ for (const [, req] of this.pendingRequests) {
634
+ if (req.teamSlug === teamSlug) {
635
+ results.push(req.payload);
636
+ }
637
+ }
638
+ return results;
151
639
  }
152
640
 
153
641
  async onTaskCompleted(teamSlug: string, taskId: string): Promise<void> {
@@ -165,12 +653,10 @@ export class TaskDispatchService {
165
653
  result: task.result ?? undefined,
166
654
  };
167
655
 
168
- // Update local dispatchMeta
169
656
  await this.workspace.patchTask(teamSlug, taskId, {
170
657
  dispatchMeta: { ...meta, status: 'completed', completedAt: update.timestamp },
171
658
  } as any);
172
659
 
173
- // Notify origin team
174
660
  if (this.redis) {
175
661
  const channel = `task:status:${meta.originTeam}`;
176
662
  await this.redis.publish(channel, JSON.stringify(update)).catch((err: Error) => {
@@ -179,7 +665,7 @@ export class TaskDispatchService {
179
665
  }
180
666
  }
181
667
 
182
- // ── Local dispatch ────────────────────────────────────────────
668
+ // ── Local team check ─────────────────────────────────────────
183
669
 
184
670
  private async isLocalTeam(teamSlug: string): Promise<boolean> {
185
671
  try {
@@ -190,21 +676,84 @@ export class TaskDispatchService {
190
676
  }
191
677
  }
192
678
 
193
- private async handleLocalDispatch(
679
+ // ── Unified Redis dispatch ───────────────────────────────────────
680
+
681
+ private async handleRedisDispatch(
194
682
  dispatchMeta: DispatchMeta,
195
- task: { subject: string; description?: string; prompt?: string }
683
+ task: { subject: string; description?: string; prompt?: string },
684
+ needsHumanReview?: boolean
196
685
  ): Promise<void> {
197
- const created = await this.workspace.createTask(dispatchMeta.targetTeam, {
198
- title: task.subject,
199
- description: task.description,
686
+ const payload: TaskDispatchPayload = {
687
+ dispatchId: dispatchMeta.dispatchId,
688
+ originTeam: dispatchMeta.originTeam,
689
+ targetTeam: dispatchMeta.targetTeam,
690
+ task: { subject: task.subject, description: task.description, prompt: task.prompt },
691
+ dispatchedAt: dispatchMeta.dispatchedAt,
692
+ deadline: dispatchMeta.deadline,
693
+ needsHumanReview,
694
+ };
695
+
696
+ // Store in memory for accept/reject
697
+ this.pendingRequests.set(dispatchMeta.dispatchId, {
698
+ payload,
699
+ msgId: `redis-${Date.now()}`,
700
+ groupName: 'dispatch-group',
701
+ teamSlug: dispatchMeta.targetTeam,
200
702
  });
201
- // Attach dispatchMeta after creation
202
- await this.workspace.patchTask(dispatchMeta.targetTeam, created.id, {
203
- dispatchMeta,
204
- } as any);
703
+
704
+ // Publish to Redis stream
705
+ const streamKey = `task:dispatch:${dispatchMeta.targetTeam}`;
706
+ await this.redis!.xadd(streamKey, '*', 'payload', JSON.stringify(payload));
707
+
708
+ // If local team, also write to inbox
709
+ const isLocal = await this.isLocalTeam(dispatchMeta.targetTeam);
710
+ if (isLocal) {
711
+ try {
712
+ await this.workspace.appendMessage(dispatchMeta.targetTeam, {
713
+ from: dispatchMeta.originTeam,
714
+ to: 'team',
715
+ role: 'agent',
716
+ content: `[跨团队任务] ${task.subject}${task.description ? '\n' + task.description : ''}`,
717
+ meta: {
718
+ source: 'cross_team_dispatch',
719
+ dispatchId: dispatchMeta.dispatchId,
720
+ originTeam: dispatchMeta.originTeam,
721
+ needsHumanReview,
722
+ },
723
+ });
724
+ } catch (err) {
725
+ console.error('[TaskDispatchService] inbox write failed:', (err as Error).message);
726
+ }
727
+ }
728
+
729
+ this.sendFeishuNotification(
730
+ `跨团队任务派发:${dispatchMeta.originTeam} → ${dispatchMeta.targetTeam}\n${task.subject}`
731
+ );
205
732
  }
206
733
 
207
- // ── Remote dispatch (Redis) ───────────────────────────────────
734
+ // ── Feishu notification helper ──────────────────────────────────
735
+
736
+ private sendFeishuNotification(text: string): void {
737
+ setTimeout(() => {
738
+ try {
739
+ const { execSync } = require('node:child_process');
740
+ execSync(
741
+ `feishu-cli msg send --receive-id-type chat_id --receive-id oc_e7d4204895f8f9d763d9f0e42ead1e5e --text ${JSON.stringify(text)}`,
742
+ { timeout: 5000, stdio: 'pipe' }
743
+ );
744
+ } catch {
745
+ // best effort
746
+ }
747
+ }, 0);
748
+ }
749
+
750
+ // ── Local response (same machine) ─────────────────────────────
751
+
752
+ private async handleLocalResponse(response: TaskHandshakeResponse): Promise<void> {
753
+ await this.applyResponse(response);
754
+ }
755
+
756
+ // ── Redis connection ──────────────────────────────────────────
208
757
 
209
758
  private async connectRedis(): Promise<void> {
210
759
  if (!this.config?.redis) return;
@@ -222,8 +771,10 @@ export class TaskDispatchService {
222
771
 
223
772
  await this.redis.ping();
224
773
 
774
+ this.collabBoard.setRedis(this.redis);
225
775
  this.startHeartbeat();
226
776
  this.startConsumers();
777
+ this.startResponseConsumers();
227
778
  this.subscribeStatus();
228
779
  } catch (err) {
229
780
  console.error('[TaskDispatchService] Redis connect failed:', err);
@@ -232,27 +783,7 @@ export class TaskDispatchService {
232
783
  }
233
784
  }
234
785
 
235
- private async handleRemoteDispatch(
236
- dispatchMeta: DispatchMeta,
237
- task: { subject: string; description?: string; prompt?: string }
238
- ): Promise<void> {
239
- const payload: TaskDispatchPayload = {
240
- dispatchId: dispatchMeta.dispatchId,
241
- originTeam: dispatchMeta.originTeam,
242
- targetTeam: dispatchMeta.targetTeam,
243
- task: {
244
- subject: task.subject,
245
- description: task.description,
246
- prompt: task.prompt,
247
- },
248
- dispatchedAt: dispatchMeta.dispatchedAt,
249
- };
250
-
251
- const streamKey = `task:dispatch:${dispatchMeta.targetTeam}`;
252
- await this.redis!.xadd(streamKey, '*', 'payload', JSON.stringify(payload));
253
- }
254
-
255
- // ── Heartbeat ─────────────────────────────────────────────────
786
+ // ── Heartbeat + agent info ────────────────────────────────────
256
787
 
257
788
  private startHeartbeat(): void {
258
789
  this.stopHeartbeat();
@@ -262,7 +793,21 @@ export class TaskDispatchService {
262
793
  const localTeams = await this.workspace.listTeams();
263
794
  for (const team of localTeams) {
264
795
  await this.redis.zadd('task:teams', now, team.slug).catch(() => {});
796
+ await this.redis
797
+ .hset(`task:team:info:${team.slug}`, {
798
+ slug: team.slug,
799
+ displayName: team.displayName ?? team.slug,
800
+ harness: team.harness,
801
+ description: team.description ?? '',
802
+ capabilities: JSON.stringify(this.inferCapabilities(team)),
803
+ collaboration: String(team.collaboration !== false),
804
+ updatedAt: new Date().toISOString(),
805
+ })
806
+ .catch(() => {});
265
807
  }
808
+
809
+ // Check deadline timeouts
810
+ await this.checkDeadlines(localTeams);
266
811
  };
267
812
  beat();
268
813
  this.heartbeatTimer = setInterval(beat, 30_000);
@@ -275,21 +820,47 @@ export class TaskDispatchService {
275
820
  }
276
821
  }
277
822
 
278
- // ── Consumers (XREADGROUP) ────────────────────────────────────
823
+ private async checkDeadlines(localTeams: TeamManifest[]): Promise<void> {
824
+ for (const team of localTeams) {
825
+ try {
826
+ const tasks = await this.workspace.readTasks(team.slug);
827
+ for (const task of tasks) {
828
+ if (
829
+ task.dispatchMeta?.status === 'pending_accept' &&
830
+ task.dispatchMeta.deadline &&
831
+ new Date(task.dispatchMeta.deadline).getTime() < Date.now()
832
+ ) {
833
+ await this.workspace.patchTask(team.slug, task.id, {
834
+ dispatchMeta: {
835
+ ...task.dispatchMeta,
836
+ status: 'failed',
837
+ rejectionReason: 'handshake timeout',
838
+ },
839
+ } as any);
840
+ }
841
+ }
842
+ } catch {
843
+ /* skip broken teams */
844
+ }
845
+ }
846
+ }
847
+
848
+ // ── Dispatch consumers (XREADGROUP) ───────────────────────────
279
849
 
280
850
  private startConsumers(): void {
281
851
  if (!this.redis || !this.redisSub) return;
282
852
 
283
853
  const startForTeam = async (teamSlug: string) => {
854
+ if (this.consumerTeamSlugs.has(teamSlug)) return;
855
+ this.consumerTeamSlugs.add(teamSlug);
284
856
  const streamKey = `task:dispatch:${teamSlug}`;
285
857
  const groupName = `hermit-${teamSlug}`;
286
858
  const consumerId = `consumer-${process.pid}`;
287
859
 
288
- // Create consumer group (MKSTREAM creates stream if missing)
289
860
  try {
290
861
  await this.redis!.xgroup('CREATE', streamKey, groupName, '0', 'MKSTREAM');
291
862
  } catch {
292
- // Group already exists — ignore
863
+ // Group already exists
293
864
  }
294
865
 
295
866
  const poll = async () => {
@@ -322,18 +893,19 @@ export class TaskDispatchService {
322
893
  }
323
894
  };
324
895
 
325
- // Initial poll, then interval
326
896
  poll();
327
897
  const timer = setInterval(poll, 5000);
328
898
  this.consumerTimers.push(timer);
329
899
  };
330
900
 
331
- // Start consumer for each local team
332
- this.workspace.listTeams().then((teams) => {
333
- for (const team of teams) {
334
- startForTeam(team.slug);
335
- }
336
- });
901
+ const syncConsumers = () =>
902
+ void this.workspace.listTeams().then((teams) => {
903
+ for (const team of teams) {
904
+ void startForTeam(team.slug);
905
+ }
906
+ });
907
+ syncConsumers();
908
+ this.consumerTimers.push(setInterval(syncConsumers, 10_000));
337
909
  }
338
910
 
339
911
  private async handleIncomingDispatch(
@@ -343,44 +915,87 @@ export class TaskDispatchService {
343
915
  groupName: string
344
916
  ): Promise<void> {
345
917
  try {
346
- // fields is [key, value, key, value, ...]
347
918
  const payloadStr = fields[1]?.toString();
348
919
  if (!payloadStr) return;
349
920
 
350
921
  const payload: TaskDispatchPayload = JSON.parse(payloadStr);
922
+ const alreadyPending = this.pendingRequests.has(payload.dispatchId);
351
923
 
352
- // Write task to board.json
353
- const created = await this.workspace.createTask(teamSlug, {
354
- title: payload.task.subject,
924
+ const fromTeamManifest = await this.safeReadManifest(payload.originTeam);
925
+ const toTeamManifest = await this.safeReadManifest(teamSlug);
926
+ const createdAt = payload.dispatchedAt || new Date().toISOString();
927
+ this.collabBoard.addTask({
928
+ id: payload.dispatchId,
929
+ dispatchId: payload.dispatchId,
930
+ subject: payload.task.subject,
355
931
  description: payload.task.description,
932
+ fromTeam: payload.originTeam,
933
+ fromTeamDisplay: fromTeamManifest?.displayName ?? payload.originTeam,
934
+ toTeam: teamSlug,
935
+ toTeamDisplay: toTeamManifest?.displayName ?? teamSlug,
936
+ status: 'pending_accept',
937
+ deadline: payload.deadline,
938
+ needsHumanReview: payload.needsHumanReview ?? false,
939
+ revisionCount: 0,
940
+ createdAt,
941
+ updatedAt: createdAt,
356
942
  });
943
+ this.emitCollabChange(payload.dispatchId, 'pending_accept', payload.originTeam, teamSlug);
357
944
 
358
- const dispatchMeta: DispatchMeta = {
359
- dispatchId: payload.dispatchId,
360
- originTeam: payload.originTeam,
361
- targetTeam: payload.targetTeam,
362
- status: 'received',
363
- dispatchedAt: payload.dispatchedAt,
364
- receivedAt: new Date().toISOString(),
365
- remoteTaskId: created.id,
366
- };
945
+ const existingTasks = await this.workspace.readTasks(teamSlug).catch(() => []);
946
+ const existingTask = existingTasks.find(
947
+ (task) => task.dispatchMeta?.dispatchId === payload.dispatchId
948
+ );
949
+ const localTask =
950
+ existingTask ??
951
+ (await this.workspace.createTask(teamSlug, {
952
+ title: payload.task.subject,
953
+ description: payload.task.description ?? payload.task.prompt ?? '',
954
+ status: 'todo',
955
+ dispatchMeta: {
956
+ dispatchId: payload.dispatchId,
957
+ originTeam: payload.originTeam,
958
+ targetTeam: teamSlug,
959
+ status: 'pending_accept',
960
+ dispatchedAt: payload.dispatchedAt,
961
+ receivedAt: new Date().toISOString(),
962
+ deadline: payload.deadline,
963
+ },
964
+ }));
367
965
 
368
- await this.workspace.patchTask(teamSlug, created.id, {
369
- dispatchMeta,
370
- } as any);
966
+ // Store in pending requests — wait for a human to accept/reject the agent-created dispatch.
967
+ this.pendingRequests.set(payload.dispatchId, {
968
+ payload,
969
+ msgId,
970
+ groupName,
971
+ teamSlug,
972
+ localTaskId: localTask.id,
973
+ });
371
974
 
372
- // Send ack
373
- if (this.redis) {
374
- const ackKey = `task:ack:${payload.dispatchId}`;
375
- const ackPayload = JSON.stringify({
376
- dispatchId: payload.dispatchId,
377
- status: 'received',
378
- remoteTaskId: created.id,
379
- timestamp: new Date().toISOString(),
380
- });
381
- await this.redis.xadd(ackKey, '*', 'ack', ackPayload);
382
- await this.redis.xack(`task:dispatch:${teamSlug}`, groupName, msgId);
975
+ if (!alreadyPending) {
976
+ await this.workspace
977
+ .appendMessage(teamSlug, {
978
+ from: payload.originTeam,
979
+ to: 'team',
980
+ role: 'agent',
981
+ content: `[跨团队任务] ${payload.task.subject}${
982
+ payload.task.description ? '\n' + payload.task.description : ''
983
+ }`,
984
+ meta: {
985
+ source: 'cross_team_dispatch',
986
+ dispatchId: payload.dispatchId,
987
+ originTeam: payload.originTeam,
988
+ needsHumanReview: payload.needsHumanReview,
989
+ },
990
+ })
991
+ .catch((err: Error) => {
992
+ console.error('[TaskDispatchService] inbox write failed:', err.message);
993
+ });
383
994
  }
995
+
996
+ console.log(
997
+ `[TaskDispatchService] received dispatch request: ${payload.dispatchId} from ${payload.originTeam} → ${teamSlug}`
998
+ );
384
999
  } catch (err) {
385
1000
  console.error('[TaskDispatchService] handleIncomingDispatch error:', err);
386
1001
  }
@@ -389,9 +1004,155 @@ export class TaskDispatchService {
389
1004
  private stopConsumers(): void {
390
1005
  for (const t of this.consumerTimers) clearInterval(t);
391
1006
  this.consumerTimers = [];
1007
+ this.consumerTeamSlugs.clear();
1008
+ }
1009
+
1010
+ // ── Response consumers (XREADGROUP) ───────────────────────────
1011
+
1012
+ private startResponseConsumers(): void {
1013
+ if (!this.redis || !this.redisSub) return;
1014
+
1015
+ const startForTeam = async (teamSlug: string) => {
1016
+ if (this.responseConsumerTeamSlugs.has(teamSlug)) return;
1017
+ this.responseConsumerTeamSlugs.add(teamSlug);
1018
+ const streamKey = `task:response:${teamSlug}`;
1019
+ const groupName = `hermit-response-${teamSlug}`;
1020
+ const consumerId = `response-consumer-${process.pid}`;
1021
+
1022
+ try {
1023
+ await this.redis!.xgroup('CREATE', streamKey, groupName, '0', 'MKSTREAM');
1024
+ } catch {
1025
+ // Group already exists
1026
+ }
1027
+
1028
+ const poll = async () => {
1029
+ if (this.disposed || !this.redisSub) return;
1030
+ try {
1031
+ const raw: unknown = await (this.redisSub as any).xreadgroup(
1032
+ 'GROUP',
1033
+ groupName,
1034
+ consumerId,
1035
+ 'BLOCK',
1036
+ 5000,
1037
+ 'COUNT',
1038
+ 1,
1039
+ 'STREAMS',
1040
+ streamKey,
1041
+ '>'
1042
+ );
1043
+ const results = raw as [string, [string, (string | Buffer)[]][]][] | null;
1044
+
1045
+ if (!results || results.length === 0) return;
1046
+
1047
+ for (const [, messages] of results) {
1048
+ if (!Array.isArray(messages)) continue;
1049
+ for (const [msgId, fields] of messages) {
1050
+ await this.handleIncomingResponse(teamSlug, msgId, fields, groupName);
1051
+ }
1052
+ }
1053
+ } catch {
1054
+ // Read error — will retry next poll
1055
+ }
1056
+ };
1057
+
1058
+ poll();
1059
+ const timer = setInterval(poll, 5000);
1060
+ this.responseConsumerTimers.push(timer);
1061
+ };
1062
+
1063
+ const syncConsumers = () =>
1064
+ void this.workspace.listTeams().then((teams) => {
1065
+ for (const team of teams) {
1066
+ void startForTeam(team.slug);
1067
+ }
1068
+ });
1069
+ syncConsumers();
1070
+ this.responseConsumerTimers.push(setInterval(syncConsumers, 10_000));
392
1071
  }
393
1072
 
394
- // ── Status subscribe ──────────────────────────────────────────
1073
+ private async handleIncomingResponse(
1074
+ _teamSlug: string,
1075
+ msgId: string,
1076
+ fields: (string | Buffer)[],
1077
+ groupName: string
1078
+ ): Promise<void> {
1079
+ try {
1080
+ const payloadStr = fields[1]?.toString();
1081
+ if (!payloadStr) return;
1082
+
1083
+ const response: TaskHandshakeResponse = JSON.parse(payloadStr);
1084
+ await this.applyResponse(response);
1085
+
1086
+ // ACK
1087
+ if (this.redis) {
1088
+ await this.redis.xack(`task:response:${_teamSlug}`, groupName, msgId).catch(() => {});
1089
+ }
1090
+ } catch (err) {
1091
+ console.error('[TaskDispatchService] handleIncomingResponse error:', err);
1092
+ }
1093
+ }
1094
+
1095
+ private async applyResponse(response: TaskHandshakeResponse): Promise<void> {
1096
+ const originTeam = response.toTeam;
1097
+ const tasks = await this.workspace.readTasks(originTeam);
1098
+ const shadowTask = tasks.find((t) => t.dispatchMeta?.dispatchId === response.dispatchId);
1099
+ if (!shadowTask) return;
1100
+
1101
+ const meta = { ...shadowTask.dispatchMeta! };
1102
+
1103
+ if (response.type === 'task_accept') {
1104
+ meta.status = 'accepted';
1105
+ meta.acceptedAt = response.acceptedAt;
1106
+ meta.remoteTaskId = response.remoteTaskId;
1107
+ // Collab board update already done in acceptTask
1108
+ } else if (response.type === 'task_reject') {
1109
+ meta.status = 'rejected';
1110
+ meta.rejectedAt = response.rejectedAt;
1111
+ meta.rejectionReason = response.reason;
1112
+ // Collab board update already done in rejectTask
1113
+ } else if (response.type === 'task_deliver') {
1114
+ meta.status = 'completed';
1115
+ meta.completedAt = response.deliveredAt;
1116
+ await this.workspace.patchTask(originTeam, shadowTask.id, {
1117
+ dispatchMeta: meta,
1118
+ } as any);
1119
+
1120
+ // Auto-approve if no human review needed
1121
+ const collabTask = this.collabBoard.getTask(response.dispatchId);
1122
+ if (collabTask && !collabTask.needsHumanReview && collabTask.status === 'delivered') {
1123
+ const approvedAt = new Date().toISOString();
1124
+ this.collabBoard.transition({
1125
+ dispatchId: response.dispatchId,
1126
+ expected: 'delivered',
1127
+ next: 'approved',
1128
+ actor: { type: 'system', id: 'auto-approve' },
1129
+ eventType: 'task_approved',
1130
+ payload: { auto: true },
1131
+ extra: { approvedAt },
1132
+ });
1133
+ this.emitCollabChange(response.dispatchId, 'approved', response.fromTeam, response.toTeam);
1134
+ }
1135
+ return;
1136
+ } else if (response.type === 'task_approve') {
1137
+ // Received by target team — already handled in approveTask
1138
+ return;
1139
+ } else if (response.type === 'task_revision') {
1140
+ // Received by target team — already handled in rejectResult
1141
+ return;
1142
+ }
1143
+
1144
+ await this.workspace.patchTask(originTeam, shadowTask.id, {
1145
+ dispatchMeta: meta,
1146
+ } as any);
1147
+ }
1148
+
1149
+ private stopResponseConsumers(): void {
1150
+ for (const t of this.responseConsumerTimers) clearInterval(t);
1151
+ this.responseConsumerTimers = [];
1152
+ this.responseConsumerTeamSlugs.clear();
1153
+ }
1154
+
1155
+ // ── Status subscribe (completion notifications) ──────────────
395
1156
 
396
1157
  private subscribeStatus(): void {
397
1158
  if (!this.redisSub) return;
@@ -407,14 +1168,11 @@ export class TaskDispatchService {
407
1168
  });
408
1169
 
409
1170
  this.redisSub.on('message', (channel: string, message: string) => {
410
- // channel format: task:status:{teamSlug}
411
1171
  if (!channel.startsWith('task:status:')) return;
412
1172
 
413
1173
  try {
414
1174
  const update: TaskStatusUpdate = JSON.parse(message);
415
1175
  const teamSlug = channel.replace('task:status:', '');
416
-
417
- // Find shadow task (dispatched from this team) and update status
418
1176
  this.handleStatusSync(teamSlug, update);
419
1177
  } catch {
420
1178
  // Ignore malformed messages
@@ -437,4 +1195,34 @@ export class TaskDispatchService {
437
1195
  },
438
1196
  } as any);
439
1197
  }
1198
+
1199
+ // ── Capability inference ──────────────────────────────────────
1200
+
1201
+ private inferCapabilities(team: TeamManifest): AgentCapability[] {
1202
+ const caps: AgentCapability[] = [];
1203
+ if (team.harness) {
1204
+ caps.push({ skill: team.harness, description: `${team.harness} agent` });
1205
+ }
1206
+ if (team.description) {
1207
+ caps.push({ skill: 'general', description: team.description });
1208
+ }
1209
+ return caps;
1210
+ }
1211
+
1212
+ private async safeReadManifest(teamSlug: string): Promise<TeamManifest | null> {
1213
+ try {
1214
+ return await this.workspace.readTeamManifest(teamSlug);
1215
+ } catch {
1216
+ return null;
1217
+ }
1218
+ }
1219
+
1220
+ private emitCollabChange(
1221
+ dispatchId: string,
1222
+ status: string,
1223
+ fromTeam: string,
1224
+ toTeam: string
1225
+ ): void {
1226
+ this.onCollabChange?.(dispatchId, status, fromTeam, toTeam);
1227
+ }
440
1228
  }