@yancyyu/openhermit 1.6.4 → 1.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -140
- package/bin/hermit.mjs +39 -258
- package/dist-renderer/assets/{ProjectEditorOverlay-BcjkdR8y.js → ProjectEditorOverlay-14yC9eQy.js} +1 -1
- package/dist-renderer/assets/{TeamGraphOverlay-B9PP0b_t.js → TeamGraphOverlay-RAoJDOnS.js} +1 -1
- package/dist-renderer/assets/{_basePickBy-CPquAmj5.js → _basePickBy-BhDOA0cG.js} +1 -1
- package/dist-renderer/assets/{_baseUniq-A66EsJn2.js → _baseUniq-DjjY0tMN.js} +1 -1
- package/dist-renderer/assets/{arc-YLxbV3Qw.js → arc-CzoaaE90.js} +1 -1
- package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-wwpiLSwy.js → architectureDiagram-VXUJARFQ-D7ZTVCML.js} +1 -1
- package/dist-renderer/assets/{blockDiagram-VD42YOAC-3CHE3NYR.js → blockDiagram-VD42YOAC-DDVOvV1H.js} +1 -1
- package/dist-renderer/assets/{c4Diagram-YG6GDRKO-K8hDNmEC.js → c4Diagram-YG6GDRKO-CMswQy_R.js} +1 -1
- package/dist-renderer/assets/channel-DjoT-21b.js +1 -0
- package/dist-renderer/assets/{chunk-4BX2VUAB-5OabZrhH.js → chunk-4BX2VUAB-aYfdMo75.js} +1 -1
- package/dist-renderer/assets/{chunk-55IACEB6-v2kdM_aT.js → chunk-55IACEB6-DUhZJ0mV.js} +1 -1
- package/dist-renderer/assets/{chunk-B4BG7PRW-C0Ju56SH.js → chunk-B4BG7PRW-BrGjG-E6.js} +1 -1
- package/dist-renderer/assets/{chunk-DI55MBZ5-DPTWTKRm.js → chunk-DI55MBZ5-CfPUMKlq.js} +1 -1
- package/dist-renderer/assets/{chunk-FMBD7UC4-DSkYppkv.js → chunk-FMBD7UC4-BMr0Vrdu.js} +1 -1
- package/dist-renderer/assets/{chunk-QN33PNHL-C_4cCLCl.js → chunk-QN33PNHL-C9gTfFZV.js} +1 -1
- package/dist-renderer/assets/{chunk-QZHKN3VN-ojL7PmOD.js → chunk-QZHKN3VN-TTPdfwHP.js} +1 -1
- package/dist-renderer/assets/{chunk-TZMSLE5B-D1g7Vl_v.js → chunk-TZMSLE5B-DPh3DBqf.js} +1 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-C5mL3TLG.js +1 -0
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-C5mL3TLG.js +1 -0
- package/dist-renderer/assets/clone-cS8bapaK.js +1 -0
- package/dist-renderer/assets/{cose-bilkent-S5V4N54A-TJnGh924.js → cose-bilkent-S5V4N54A-BFbgRWOS.js} +1 -1
- package/dist-renderer/assets/{dagre-6UL2VRFP-cPgfHhoX.js → dagre-6UL2VRFP-CfXdU7Il.js} +1 -1
- package/dist-renderer/assets/{diagram-PSM6KHXK-BS5Y-RR6.js → diagram-PSM6KHXK-MdOyrxZl.js} +1 -1
- package/dist-renderer/assets/{diagram-QEK2KX5R-D9AF7AGJ.js → diagram-QEK2KX5R-DpmnBR-A.js} +1 -1
- package/dist-renderer/assets/{diagram-S2PKOQOG-DTFUadMS.js → diagram-S2PKOQOG-JXgp2H5I.js} +1 -1
- package/dist-renderer/assets/{erDiagram-Q2GNP2WA-DB_StEwC.js → erDiagram-Q2GNP2WA-CRfYO8W3.js} +1 -1
- package/dist-renderer/assets/{flowDiagram-NV44I4VS-DGn40aPj.js → flowDiagram-NV44I4VS-BJvmgply.js} +1 -1
- package/dist-renderer/assets/{ganttDiagram-JELNMOA3-9NiFCSBT.js → ganttDiagram-JELNMOA3-BLXnpZat.js} +1 -1
- package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-BdveeU3c.js → gitGraphDiagram-V2S2FVAM-BKtbxazQ.js} +1 -1
- package/dist-renderer/assets/{graph-aQYbgTDH.js → graph-D6n4zNVe.js} +1 -1
- package/dist-renderer/assets/{index-DmgKTZAa.js → index-3JdA9Dab.js} +529 -524
- package/dist-renderer/assets/{index-CaG9mf8s.css → index-C4x095x4.css} +1 -1
- package/dist-renderer/assets/{index-CWqPn0NY.js → index-CVdwMXdQ.js} +1 -1
- package/dist-renderer/assets/{index-DyEKO6GV.js → index-CkO1A9ft.js} +1 -1
- package/dist-renderer/assets/{index-oyepEosi.js → index-ar0tAtBS.js} +1 -1
- package/dist-renderer/assets/{index-CrCHolXN.js → index-c2GABSvo.js} +1 -1
- package/dist-renderer/assets/{index-DiAK42nd.js → index-trDFOqz-.js} +1 -1
- package/dist-renderer/assets/{infoDiagram-HS3SLOUP-Dmc_xn8U.js → infoDiagram-HS3SLOUP-Bqq_toop.js} +1 -1
- package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-D9LJr-B5.js → journeyDiagram-XKPGCS4Q-BRQs07r0.js} +1 -1
- package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-CjOWoNys.js → kanban-definition-3W4ZIXB7-DHQnAijJ.js} +1 -1
- package/dist-renderer/assets/{layout-D6GzYK4K.js → layout-BljiazG5.js} +1 -1
- package/dist-renderer/assets/{linear-Dt3GyUQf.js → linear-fx8cDfux.js} +1 -1
- package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-XwY2hZr8.js → mindmap-definition-VGOIOE7T-DCfQbCFK.js} +1 -1
- package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BU4nfYd7.js → pieDiagram-ADFJNKIX-DyAFYy6H.js} +1 -1
- package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-BYk6f63x.js → quadrantDiagram-AYHSOK5B-CCvqn9gd.js} +1 -1
- package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-kbadr_bU.js → requirementDiagram-UZGBJVZJ-JYde-Xl2.js} +1 -1
- package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-ZstP2Vth.js → sankeyDiagram-TZEHDZUN-C2Im6-aG.js} +1 -1
- package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-obK_-ssz.js → sequenceDiagram-WL72ISMW-X6JGIoEB.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-BgZDg0VT.js → stateDiagram-FKZM4ZOC-BJTDs8MY.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-CMa5sz7x.js → stateDiagram-v2-4FDKWEC3-DUrYslPS.js} +1 -1
- package/dist-renderer/assets/{timeline-definition-IT6M3QCI-BOmCNnab.js → timeline-definition-IT6M3QCI-C7ECznev.js} +1 -1
- package/dist-renderer/assets/{treemap-GDKQZRPO-BU0ha0Ww.js → treemap-GDKQZRPO-BRg3Zpk4.js} +1 -1
- package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-BzAHNASi.js → xychartDiagram-PRI3JC2R-CoZGyc2f.js} +1 -1
- package/dist-renderer/index.html +2 -2
- package/package.json +23 -17
- package/src/main/server.ts +179 -23
- package/src/main/services/teams-mvp/TaskDispatchService.ts +440 -0
- package/src/main/services/teams-mvp/TeamProvisioningService.ts +36 -33
- package/src/main/services/teams-mvp/TeamWorkspaceService.ts +2 -0
- package/src/renderer/components/settings/SettingsTabs.tsx +8 -2
- package/src/renderer/components/settings/SettingsView.tsx +4 -0
- package/src/renderer/components/settings/sections/GeneralSection.tsx +168 -206
- package/src/renderer/components/settings/sections/TaskBusSection.tsx +176 -0
- package/src/renderer/components/sidebar/SidebarSessions.tsx +31 -4
- package/src/renderer/components/team/kanban/KanbanTaskCard.tsx +37 -0
- package/src/renderer/components/team/messages/MessageComposer.tsx +36 -228
- package/src/renderer/components/team/messages/MessagesPanel.tsx +0 -3
- package/src/renderer/store/slices/teamSlice.ts +30 -1
- package/src/shared/types/team.ts +73 -0
- package/dist-renderer/assets/channel-BSWYOYIc.js +0 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-mw4yABob.js +0 -1
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-mw4yABob.js +0 -1
- package/dist-renderer/assets/clone-KtZfFt-o.js +0 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiscoverableTeam,
|
|
3
|
+
DispatchMeta,
|
|
4
|
+
TaskBusConfig,
|
|
5
|
+
TaskDispatchPayload,
|
|
6
|
+
TaskStatusUpdate,
|
|
7
|
+
} from '@shared/types/team';
|
|
8
|
+
import type { TeamWorkspaceService, TeamManifest } from './TeamWorkspaceService';
|
|
9
|
+
import type Redis from 'ioredis';
|
|
10
|
+
|
|
11
|
+
const DISPATCH_RULES_DEFAULT = `When to dispatch a task to another team:
|
|
12
|
+
- Task requires access to a different codebase/project
|
|
13
|
+
- Task explicitly mentions another team's domain or ownership
|
|
14
|
+
- Task is blocked by work owned by another team
|
|
15
|
+
- Task requires expertise the current team doesn't have
|
|
16
|
+
|
|
17
|
+
Do NOT dispatch:
|
|
18
|
+
- Task is within current team's project scope
|
|
19
|
+
- Task can be completed with available tools
|
|
20
|
+
- Task is a small change (< estimated 5 min)`;
|
|
21
|
+
|
|
22
|
+
export interface DispatchResult {
|
|
23
|
+
dispatchId: string;
|
|
24
|
+
status: DispatchMeta['status'];
|
|
25
|
+
targetTeam: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class TaskDispatchService {
|
|
30
|
+
private workspace: TeamWorkspaceService;
|
|
31
|
+
private config: TaskBusConfig | null = null;
|
|
32
|
+
private redis: Redis | null = null;
|
|
33
|
+
private redisSub: Redis | null = null;
|
|
34
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
private consumerTimers: ReturnType<typeof setInterval>[] = [];
|
|
36
|
+
private disposed = false;
|
|
37
|
+
|
|
38
|
+
constructor(workspace: TeamWorkspaceService) {
|
|
39
|
+
this.workspace = workspace;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get dispatchRulesText(): string {
|
|
43
|
+
return DISPATCH_RULES_DEFAULT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(config?: TaskBusConfig): Promise<void> {
|
|
47
|
+
this.config = config ?? null;
|
|
48
|
+
if (config?.enabled && config.redis) {
|
|
49
|
+
await this.connectRedis();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
dispose(): void {
|
|
54
|
+
this.disposed = true;
|
|
55
|
+
this.stopHeartbeat();
|
|
56
|
+
this.stopConsumers();
|
|
57
|
+
this.redis?.disconnect();
|
|
58
|
+
this.redisSub?.disconnect();
|
|
59
|
+
this.redis = null;
|
|
60
|
+
this.redisSub = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Agent-facing ──────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
async listTeams(): Promise<DiscoverableTeam[]> {
|
|
66
|
+
const teams: DiscoverableTeam[] = [];
|
|
67
|
+
|
|
68
|
+
// Local teams
|
|
69
|
+
const localTeams = await this.workspace.listTeams();
|
|
70
|
+
for (const team of localTeams) {
|
|
71
|
+
teams.push({
|
|
72
|
+
slug: team.slug,
|
|
73
|
+
displayName: team.displayName ?? team.slug,
|
|
74
|
+
location: 'local',
|
|
75
|
+
status: 'online',
|
|
76
|
+
collaboration: team.collaboration !== false,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Remote teams (via Redis)
|
|
81
|
+
if (this.redis) {
|
|
82
|
+
try {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const staleThreshold = 90_000; // 90s
|
|
85
|
+
const entries = await this.redis.zrange('task:teams', 0, -1, 'WITHSCORES');
|
|
86
|
+
const localSlugs = new Set(teams.map((t) => t.slug));
|
|
87
|
+
for (let i = 0; i < entries.length; i += 2) {
|
|
88
|
+
const slug = entries[i] as string;
|
|
89
|
+
const ts = Number(entries[i + 1]);
|
|
90
|
+
if (localSlugs.has(slug)) continue;
|
|
91
|
+
teams.push({
|
|
92
|
+
slug,
|
|
93
|
+
displayName: slug,
|
|
94
|
+
location: 'remote',
|
|
95
|
+
status: now - ts < staleThreshold ? 'online' : 'offline',
|
|
96
|
+
collaboration: true,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Redis read failure — return local teams only
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return teams;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async dispatchTask(
|
|
108
|
+
fromTeam: string,
|
|
109
|
+
task: { subject: string; description?: string; prompt?: string },
|
|
110
|
+
targetTeam: string
|
|
111
|
+
): Promise<DispatchResult> {
|
|
112
|
+
if (fromTeam === targetTeam) {
|
|
113
|
+
return {
|
|
114
|
+
dispatchId: '',
|
|
115
|
+
status: 'failed',
|
|
116
|
+
targetTeam,
|
|
117
|
+
message: 'Cannot dispatch to self — use native task tools instead.',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const dispatchId = crypto.randomUUID();
|
|
122
|
+
const dispatchMeta: DispatchMeta = {
|
|
123
|
+
dispatchId,
|
|
124
|
+
originTeam: fromTeam,
|
|
125
|
+
targetTeam,
|
|
126
|
+
status: 'dispatched',
|
|
127
|
+
dispatchedAt: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
|
|
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 {
|
|
137
|
+
return {
|
|
138
|
+
dispatchId,
|
|
139
|
+
status: 'failed',
|
|
140
|
+
targetTeam,
|
|
141
|
+
message: 'Redis not configured — remote dispatch unavailable.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
dispatchId,
|
|
147
|
+
status: 'dispatched',
|
|
148
|
+
targetTeam,
|
|
149
|
+
message: `Task dispatched to ${targetTeam}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async onTaskCompleted(teamSlug: string, taskId: string): Promise<void> {
|
|
154
|
+
const tasks = await this.workspace.readTasks(teamSlug);
|
|
155
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
156
|
+
if (!task?.dispatchMeta) return;
|
|
157
|
+
|
|
158
|
+
const meta = task.dispatchMeta;
|
|
159
|
+
const update: TaskStatusUpdate = {
|
|
160
|
+
dispatchId: meta.dispatchId,
|
|
161
|
+
originTeam: meta.originTeam,
|
|
162
|
+
status: 'completed',
|
|
163
|
+
remoteTaskId: task.id,
|
|
164
|
+
timestamp: new Date().toISOString(),
|
|
165
|
+
result: task.result ?? undefined,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Update local dispatchMeta
|
|
169
|
+
await this.workspace.patchTask(teamSlug, taskId, {
|
|
170
|
+
dispatchMeta: { ...meta, status: 'completed', completedAt: update.timestamp },
|
|
171
|
+
} as any);
|
|
172
|
+
|
|
173
|
+
// Notify origin team
|
|
174
|
+
if (this.redis) {
|
|
175
|
+
const channel = `task:status:${meta.originTeam}`;
|
|
176
|
+
await this.redis.publish(channel, JSON.stringify(update)).catch((err: Error) => {
|
|
177
|
+
console.error('[TaskDispatchService] status publish failed:', err.message);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Local dispatch ────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
private async isLocalTeam(teamSlug: string): Promise<boolean> {
|
|
185
|
+
try {
|
|
186
|
+
await this.workspace.readTeamManifest(teamSlug);
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async handleLocalDispatch(
|
|
194
|
+
dispatchMeta: DispatchMeta,
|
|
195
|
+
task: { subject: string; description?: string; prompt?: string }
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const created = await this.workspace.createTask(dispatchMeta.targetTeam, {
|
|
198
|
+
title: task.subject,
|
|
199
|
+
description: task.description,
|
|
200
|
+
});
|
|
201
|
+
// Attach dispatchMeta after creation
|
|
202
|
+
await this.workspace.patchTask(dispatchMeta.targetTeam, created.id, {
|
|
203
|
+
dispatchMeta,
|
|
204
|
+
} as any);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Remote dispatch (Redis) ───────────────────────────────────
|
|
208
|
+
|
|
209
|
+
private async connectRedis(): Promise<void> {
|
|
210
|
+
if (!this.config?.redis) return;
|
|
211
|
+
try {
|
|
212
|
+
const ioredis = await import('ioredis');
|
|
213
|
+
const { host, port, password, db } = this.config.redis;
|
|
214
|
+
const opts = { host, port, password: password || undefined, db: db ?? 0 };
|
|
215
|
+
|
|
216
|
+
this.redis = new ioredis.default(opts);
|
|
217
|
+
this.redisSub = new ioredis.default(opts);
|
|
218
|
+
|
|
219
|
+
this.redis.on('error', (err: Error) => {
|
|
220
|
+
console.error('[TaskDispatchService] Redis error:', err.message);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await this.redis.ping();
|
|
224
|
+
|
|
225
|
+
this.startHeartbeat();
|
|
226
|
+
this.startConsumers();
|
|
227
|
+
this.subscribeStatus();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error('[TaskDispatchService] Redis connect failed:', err);
|
|
230
|
+
this.redis = null;
|
|
231
|
+
this.redisSub = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
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 ─────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
private startHeartbeat(): void {
|
|
258
|
+
this.stopHeartbeat();
|
|
259
|
+
const beat = async () => {
|
|
260
|
+
if (!this.redis || this.disposed) return;
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
const localTeams = await this.workspace.listTeams();
|
|
263
|
+
for (const team of localTeams) {
|
|
264
|
+
await this.redis.zadd('task:teams', now, team.slug).catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
beat();
|
|
268
|
+
this.heartbeatTimer = setInterval(beat, 30_000);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private stopHeartbeat(): void {
|
|
272
|
+
if (this.heartbeatTimer) {
|
|
273
|
+
clearInterval(this.heartbeatTimer);
|
|
274
|
+
this.heartbeatTimer = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Consumers (XREADGROUP) ────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
private startConsumers(): void {
|
|
281
|
+
if (!this.redis || !this.redisSub) return;
|
|
282
|
+
|
|
283
|
+
const startForTeam = async (teamSlug: string) => {
|
|
284
|
+
const streamKey = `task:dispatch:${teamSlug}`;
|
|
285
|
+
const groupName = `hermit-${teamSlug}`;
|
|
286
|
+
const consumerId = `consumer-${process.pid}`;
|
|
287
|
+
|
|
288
|
+
// Create consumer group (MKSTREAM creates stream if missing)
|
|
289
|
+
try {
|
|
290
|
+
await this.redis!.xgroup('CREATE', streamKey, groupName, '0', 'MKSTREAM');
|
|
291
|
+
} catch {
|
|
292
|
+
// Group already exists — ignore
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const poll = async () => {
|
|
296
|
+
if (this.disposed || !this.redisSub) return;
|
|
297
|
+
try {
|
|
298
|
+
const raw: unknown = await (this.redisSub as any).xreadgroup(
|
|
299
|
+
'GROUP',
|
|
300
|
+
groupName,
|
|
301
|
+
consumerId,
|
|
302
|
+
'BLOCK',
|
|
303
|
+
5000,
|
|
304
|
+
'COUNT',
|
|
305
|
+
1,
|
|
306
|
+
'STREAMS',
|
|
307
|
+
streamKey,
|
|
308
|
+
'>'
|
|
309
|
+
);
|
|
310
|
+
const results = raw as [string, [string, (string | Buffer)[]][]][] | null;
|
|
311
|
+
|
|
312
|
+
if (!results || results.length === 0) return;
|
|
313
|
+
|
|
314
|
+
for (const [, messages] of results) {
|
|
315
|
+
if (!Array.isArray(messages)) continue;
|
|
316
|
+
for (const [msgId, fields] of messages) {
|
|
317
|
+
await this.handleIncomingDispatch(teamSlug, msgId, fields, groupName);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// Read error — will retry next poll
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Initial poll, then interval
|
|
326
|
+
poll();
|
|
327
|
+
const timer = setInterval(poll, 5000);
|
|
328
|
+
this.consumerTimers.push(timer);
|
|
329
|
+
};
|
|
330
|
+
|
|
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
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private async handleIncomingDispatch(
|
|
340
|
+
teamSlug: string,
|
|
341
|
+
msgId: string,
|
|
342
|
+
fields: (string | Buffer)[],
|
|
343
|
+
groupName: string
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
try {
|
|
346
|
+
// fields is [key, value, key, value, ...]
|
|
347
|
+
const payloadStr = fields[1]?.toString();
|
|
348
|
+
if (!payloadStr) return;
|
|
349
|
+
|
|
350
|
+
const payload: TaskDispatchPayload = JSON.parse(payloadStr);
|
|
351
|
+
|
|
352
|
+
// Write task to board.json
|
|
353
|
+
const created = await this.workspace.createTask(teamSlug, {
|
|
354
|
+
title: payload.task.subject,
|
|
355
|
+
description: payload.task.description,
|
|
356
|
+
});
|
|
357
|
+
|
|
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
|
+
};
|
|
367
|
+
|
|
368
|
+
await this.workspace.patchTask(teamSlug, created.id, {
|
|
369
|
+
dispatchMeta,
|
|
370
|
+
} as any);
|
|
371
|
+
|
|
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);
|
|
383
|
+
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
console.error('[TaskDispatchService] handleIncomingDispatch error:', err);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private stopConsumers(): void {
|
|
390
|
+
for (const t of this.consumerTimers) clearInterval(t);
|
|
391
|
+
this.consumerTimers = [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── Status subscribe ──────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
private subscribeStatus(): void {
|
|
397
|
+
if (!this.redisSub) return;
|
|
398
|
+
|
|
399
|
+
this.workspace.listTeams().then((teams) => {
|
|
400
|
+
for (const team of teams) {
|
|
401
|
+
const channel = `task:status:${team.slug}`;
|
|
402
|
+
|
|
403
|
+
this.redisSub!.subscribe(channel).catch((err: Error) => {
|
|
404
|
+
console.error('[TaskDispatchService] subscribe failed:', err.message);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
this.redisSub.on('message', (channel: string, message: string) => {
|
|
410
|
+
// channel format: task:status:{teamSlug}
|
|
411
|
+
if (!channel.startsWith('task:status:')) return;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const update: TaskStatusUpdate = JSON.parse(message);
|
|
415
|
+
const teamSlug = channel.replace('task:status:', '');
|
|
416
|
+
|
|
417
|
+
// Find shadow task (dispatched from this team) and update status
|
|
418
|
+
this.handleStatusSync(teamSlug, update);
|
|
419
|
+
} catch {
|
|
420
|
+
// Ignore malformed messages
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async handleStatusSync(teamSlug: string, update: TaskStatusUpdate): Promise<void> {
|
|
426
|
+
const tasks = await this.workspace.readTasks(teamSlug);
|
|
427
|
+
const shadowTask = tasks.find((t) => t.dispatchMeta?.dispatchId === update.dispatchId);
|
|
428
|
+
if (!shadowTask) return;
|
|
429
|
+
|
|
430
|
+
await this.workspace.patchTask(teamSlug, shadowTask.id, {
|
|
431
|
+
dispatchMeta: {
|
|
432
|
+
...shadowTask.dispatchMeta!,
|
|
433
|
+
status: update.status,
|
|
434
|
+
completedAt:
|
|
435
|
+
update.status === 'completed' ? update.timestamp : shadowTask.dispatchMeta!.completedAt,
|
|
436
|
+
remoteTaskId: update.remoteTaskId ?? shadowTask.dispatchMeta!.remoteTaskId,
|
|
437
|
+
},
|
|
438
|
+
} as any);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 设计(v2):
|
|
5
5
|
* - 一个 Team = 一个 cc-connect project
|
|
6
|
-
* - createTeam(): 本地建目录 + cc-connect 创建 project + 注入
|
|
6
|
+
* - createTeam(): 本地建目录 + cc-connect 创建 project + 注入 CLAUDE.md 指令
|
|
7
7
|
* - dispatchTask(): assignee 变化时通过 Bridge 推消息给目标团队的 agent
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -25,12 +25,6 @@ import {
|
|
|
25
25
|
|
|
26
26
|
const logger = createLogger('TeamProvisioningService');
|
|
27
27
|
|
|
28
|
-
/** MCP server 地址,注入到 claudecode/qoder 配置 */
|
|
29
|
-
const MCP_SERVER_URL = process.env.HERMIT_MCP_URL ?? 'http://127.0.0.1:5680/mcp';
|
|
30
|
-
|
|
31
|
-
/** 支持自动注入 MCP 配置的 harness 类型 */
|
|
32
|
-
const MCP_AUTO_INJECT_HARNESS = new Set(['claudecode', 'qoder']);
|
|
33
|
-
|
|
34
28
|
export class TeamProvisioningService {
|
|
35
29
|
private readonly workspace: TeamWorkspaceService;
|
|
36
30
|
|
|
@@ -50,7 +44,7 @@ export class TeamProvisioningService {
|
|
|
50
44
|
* 创建团队:
|
|
51
45
|
* 1. 本地建目录 + team.json
|
|
52
46
|
* 2. 在 cc-connect 创建 project(bridge platform)
|
|
53
|
-
* 3.
|
|
47
|
+
* 3. 注入 CLAUDE.md 跨团队派发指令
|
|
54
48
|
* 4. 触发 cc-connect restart 激活 project
|
|
55
49
|
*/
|
|
56
50
|
async createTeam(
|
|
@@ -83,9 +77,7 @@ export class TeamProvisioningService {
|
|
|
83
77
|
}
|
|
84
78
|
}
|
|
85
79
|
|
|
86
|
-
|
|
87
|
-
await this.injectMcpConfig(manifest.workDir, slug);
|
|
88
|
-
}
|
|
80
|
+
await this.injectTeamInstructions(manifest.workDir, slug);
|
|
89
81
|
|
|
90
82
|
return { slug, manifest };
|
|
91
83
|
}
|
|
@@ -247,38 +239,49 @@ export class TeamProvisioningService {
|
|
|
247
239
|
}
|
|
248
240
|
|
|
249
241
|
// ===========================================================================
|
|
250
|
-
//
|
|
242
|
+
// CLAUDE.md instruction injection
|
|
251
243
|
// ===========================================================================
|
|
252
244
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
245
|
+
async injectTeamInstructions(workDir: string, teamSlug: string): Promise<void> {
|
|
246
|
+
const mdPath = path.join(workDir, 'CLAUDE.md');
|
|
247
|
+
const section = `
|
|
248
|
+
|
|
249
|
+
## Cross-Team Task Dispatch (Hermit)
|
|
250
|
+
|
|
251
|
+
You can dispatch tasks to other teams via the Hermit local API:
|
|
261
252
|
|
|
262
|
-
|
|
253
|
+
- **List available teams**: \`curl -s http://127.0.0.1:5680/api/cross-team/targets\`
|
|
254
|
+
- **Dispatch a task**: \`curl -s -X POST http://127.0.0.1:5680/api/cross-team/send -H 'Content-Type: application/json' -d '{"fromTeam":"${teamSlug}","toTeam":"TARGET_TEAM","subject":"Task title","description":"Optional description"}'\`
|
|
255
|
+
|
|
256
|
+
Current team slug: \`${teamSlug}\`
|
|
257
|
+
|
|
258
|
+
When to dispatch:
|
|
259
|
+
- Task requires access to a different codebase/project
|
|
260
|
+
- Task explicitly mentions another team's domain
|
|
261
|
+
- Task is blocked by work owned by another team
|
|
262
|
+
|
|
263
|
+
Do NOT dispatch:
|
|
264
|
+
- Task is within current team's project scope
|
|
265
|
+
- Task can be completed with available tools
|
|
266
|
+
`;
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
let existing = '';
|
|
263
270
|
try {
|
|
264
|
-
|
|
265
|
-
existing = JSON.parse(raw) as Record<string, unknown>;
|
|
271
|
+
existing = await fs.promises.readFile(mdPath, 'utf8');
|
|
266
272
|
} catch {
|
|
267
|
-
//
|
|
273
|
+
// File doesn't exist yet
|
|
268
274
|
}
|
|
269
275
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
env: { HERMIT_TEAM_SLUG: teamSlug },
|
|
274
|
-
};
|
|
276
|
+
if (existing.includes('Cross-Team Task Dispatch (Hermit)')) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
275
279
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
logger.info(`injected MCP config → ${settingsPath}`);
|
|
280
|
+
await fs.promises.writeFile(mdPath, existing + section, 'utf8');
|
|
281
|
+
logger.info(`injected team instructions → ${mdPath}`);
|
|
279
282
|
} catch (err) {
|
|
280
283
|
logger.warn(
|
|
281
|
-
`
|
|
284
|
+
`Team instructions injection failed: ${err instanceof Error ? err.message : String(err)}`
|
|
282
285
|
);
|
|
283
286
|
}
|
|
284
287
|
}
|
|
@@ -111,6 +111,8 @@ export interface Task {
|
|
|
111
111
|
createdAt: string;
|
|
112
112
|
updatedAt: string;
|
|
113
113
|
order: number;
|
|
114
|
+
/** Cross-team dispatch metadata */
|
|
115
|
+
dispatchMeta?: import('@shared/types/team').DispatchMeta;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
// ---------------------------------------------------------------------------
|
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
TooltipProvider,
|
|
7
7
|
TooltipTrigger,
|
|
8
8
|
} from '@renderer/components/ui/tooltip';
|
|
9
|
-
import { Bot, Info, PlugZap, Settings, Wrench } from 'lucide-react';
|
|
9
|
+
import { Bot, Info, PlugZap, Settings, Share2, Wrench } from 'lucide-react';
|
|
10
10
|
|
|
11
11
|
import type { LucideIcon } from 'lucide-react';
|
|
12
12
|
|
|
13
|
-
export type SettingsSection = 'general' | 'channels' | 'harness' | 'advanced';
|
|
13
|
+
export type SettingsSection = 'general' | 'channels' | 'harness' | 'task-bus' | 'advanced';
|
|
14
14
|
|
|
15
15
|
interface SettingsTabsProps {
|
|
16
16
|
activeSection: SettingsSection;
|
|
@@ -44,6 +44,12 @@ const tabs: TabConfig[] = [
|
|
|
44
44
|
icon: Bot,
|
|
45
45
|
description: '管理 AI Agent 运行时(12 种)的 Provider 配置、API Key、端点和 CLI 安装状态。',
|
|
46
46
|
},
|
|
47
|
+
{
|
|
48
|
+
id: 'task-bus',
|
|
49
|
+
label: '任务总线',
|
|
50
|
+
icon: Share2,
|
|
51
|
+
description: '配置 Redis 消息总线,实现跨主机的团队任务派发和状态同步。',
|
|
52
|
+
},
|
|
47
53
|
{
|
|
48
54
|
id: 'advanced',
|
|
49
55
|
label: '高级',
|
|
@@ -11,6 +11,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|
|
11
11
|
|
|
12
12
|
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
|
13
13
|
import { AdvancedSection, GeneralSection, HarnessSection, PlatformsSection } from './sections';
|
|
14
|
+
import { TaskBusSection } from './sections/TaskBusSection';
|
|
14
15
|
import { type SettingsSection, SettingsTabs } from './SettingsTabs';
|
|
15
16
|
|
|
16
17
|
export const SettingsView = (): React.JSX.Element | null => {
|
|
@@ -28,6 +29,7 @@ export const SettingsView = (): React.JSX.Element | null => {
|
|
|
28
29
|
const nextSection: SettingsSection =
|
|
29
30
|
pendingSettingsSection === 'channels' ||
|
|
30
31
|
pendingSettingsSection === 'harness' ||
|
|
32
|
+
pendingSettingsSection === 'task-bus' ||
|
|
31
33
|
pendingSettingsSection === 'advanced'
|
|
32
34
|
? pendingSettingsSection
|
|
33
35
|
: 'general';
|
|
@@ -137,6 +139,8 @@ export const SettingsView = (): React.JSX.Element | null => {
|
|
|
137
139
|
|
|
138
140
|
{activeSection === 'harness' && <HarnessSection />}
|
|
139
141
|
|
|
142
|
+
{activeSection === 'task-bus' && <TaskBusSection />}
|
|
143
|
+
|
|
140
144
|
{activeSection === 'advanced' && (
|
|
141
145
|
<AdvancedSection
|
|
142
146
|
saving={saving}
|