@yancyyu/openhermit 1.6.3 → 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.
Files changed (75) hide show
  1. package/README.md +52 -140
  2. package/bin/hermit.mjs +68 -251
  3. package/dist-renderer/assets/{ProjectEditorOverlay-BcjkdR8y.js → ProjectEditorOverlay-14yC9eQy.js} +1 -1
  4. package/dist-renderer/assets/{TeamGraphOverlay-B9PP0b_t.js → TeamGraphOverlay-RAoJDOnS.js} +1 -1
  5. package/dist-renderer/assets/{_basePickBy-CPquAmj5.js → _basePickBy-BhDOA0cG.js} +1 -1
  6. package/dist-renderer/assets/{_baseUniq-A66EsJn2.js → _baseUniq-DjjY0tMN.js} +1 -1
  7. package/dist-renderer/assets/{arc-YLxbV3Qw.js → arc-CzoaaE90.js} +1 -1
  8. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-wwpiLSwy.js → architectureDiagram-VXUJARFQ-D7ZTVCML.js} +1 -1
  9. package/dist-renderer/assets/{blockDiagram-VD42YOAC-3CHE3NYR.js → blockDiagram-VD42YOAC-DDVOvV1H.js} +1 -1
  10. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-K8hDNmEC.js → c4Diagram-YG6GDRKO-CMswQy_R.js} +1 -1
  11. package/dist-renderer/assets/channel-DjoT-21b.js +1 -0
  12. package/dist-renderer/assets/{chunk-4BX2VUAB-5OabZrhH.js → chunk-4BX2VUAB-aYfdMo75.js} +1 -1
  13. package/dist-renderer/assets/{chunk-55IACEB6-v2kdM_aT.js → chunk-55IACEB6-DUhZJ0mV.js} +1 -1
  14. package/dist-renderer/assets/{chunk-B4BG7PRW-C0Ju56SH.js → chunk-B4BG7PRW-BrGjG-E6.js} +1 -1
  15. package/dist-renderer/assets/{chunk-DI55MBZ5-DPTWTKRm.js → chunk-DI55MBZ5-CfPUMKlq.js} +1 -1
  16. package/dist-renderer/assets/{chunk-FMBD7UC4-DSkYppkv.js → chunk-FMBD7UC4-BMr0Vrdu.js} +1 -1
  17. package/dist-renderer/assets/{chunk-QN33PNHL-C_4cCLCl.js → chunk-QN33PNHL-C9gTfFZV.js} +1 -1
  18. package/dist-renderer/assets/{chunk-QZHKN3VN-ojL7PmOD.js → chunk-QZHKN3VN-TTPdfwHP.js} +1 -1
  19. package/dist-renderer/assets/{chunk-TZMSLE5B-D1g7Vl_v.js → chunk-TZMSLE5B-DPh3DBqf.js} +1 -1
  20. package/dist-renderer/assets/classDiagram-2ON5EDUG-C5mL3TLG.js +1 -0
  21. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-C5mL3TLG.js +1 -0
  22. package/dist-renderer/assets/clone-cS8bapaK.js +1 -0
  23. package/dist-renderer/assets/{cose-bilkent-S5V4N54A-TJnGh924.js → cose-bilkent-S5V4N54A-BFbgRWOS.js} +1 -1
  24. package/dist-renderer/assets/{dagre-6UL2VRFP-cPgfHhoX.js → dagre-6UL2VRFP-CfXdU7Il.js} +1 -1
  25. package/dist-renderer/assets/{diagram-PSM6KHXK-BS5Y-RR6.js → diagram-PSM6KHXK-MdOyrxZl.js} +1 -1
  26. package/dist-renderer/assets/{diagram-QEK2KX5R-D9AF7AGJ.js → diagram-QEK2KX5R-DpmnBR-A.js} +1 -1
  27. package/dist-renderer/assets/{diagram-S2PKOQOG-DTFUadMS.js → diagram-S2PKOQOG-JXgp2H5I.js} +1 -1
  28. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-DB_StEwC.js → erDiagram-Q2GNP2WA-CRfYO8W3.js} +1 -1
  29. package/dist-renderer/assets/{flowDiagram-NV44I4VS-DGn40aPj.js → flowDiagram-NV44I4VS-BJvmgply.js} +1 -1
  30. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-9NiFCSBT.js → ganttDiagram-JELNMOA3-BLXnpZat.js} +1 -1
  31. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-BdveeU3c.js → gitGraphDiagram-V2S2FVAM-BKtbxazQ.js} +1 -1
  32. package/dist-renderer/assets/{graph-aQYbgTDH.js → graph-D6n4zNVe.js} +1 -1
  33. package/dist-renderer/assets/{index-DmgKTZAa.js → index-3JdA9Dab.js} +529 -524
  34. package/dist-renderer/assets/{index-CaG9mf8s.css → index-C4x095x4.css} +1 -1
  35. package/dist-renderer/assets/{index-CWqPn0NY.js → index-CVdwMXdQ.js} +1 -1
  36. package/dist-renderer/assets/{index-DyEKO6GV.js → index-CkO1A9ft.js} +1 -1
  37. package/dist-renderer/assets/{index-oyepEosi.js → index-ar0tAtBS.js} +1 -1
  38. package/dist-renderer/assets/{index-CrCHolXN.js → index-c2GABSvo.js} +1 -1
  39. package/dist-renderer/assets/{index-DiAK42nd.js → index-trDFOqz-.js} +1 -1
  40. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-Dmc_xn8U.js → infoDiagram-HS3SLOUP-Bqq_toop.js} +1 -1
  41. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-D9LJr-B5.js → journeyDiagram-XKPGCS4Q-BRQs07r0.js} +1 -1
  42. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-CjOWoNys.js → kanban-definition-3W4ZIXB7-DHQnAijJ.js} +1 -1
  43. package/dist-renderer/assets/{layout-D6GzYK4K.js → layout-BljiazG5.js} +1 -1
  44. package/dist-renderer/assets/{linear-Dt3GyUQf.js → linear-fx8cDfux.js} +1 -1
  45. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-XwY2hZr8.js → mindmap-definition-VGOIOE7T-DCfQbCFK.js} +1 -1
  46. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BU4nfYd7.js → pieDiagram-ADFJNKIX-DyAFYy6H.js} +1 -1
  47. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-BYk6f63x.js → quadrantDiagram-AYHSOK5B-CCvqn9gd.js} +1 -1
  48. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-kbadr_bU.js → requirementDiagram-UZGBJVZJ-JYde-Xl2.js} +1 -1
  49. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-ZstP2Vth.js → sankeyDiagram-TZEHDZUN-C2Im6-aG.js} +1 -1
  50. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-obK_-ssz.js → sequenceDiagram-WL72ISMW-X6JGIoEB.js} +1 -1
  51. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-BgZDg0VT.js → stateDiagram-FKZM4ZOC-BJTDs8MY.js} +1 -1
  52. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-CMa5sz7x.js → stateDiagram-v2-4FDKWEC3-DUrYslPS.js} +1 -1
  53. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-BOmCNnab.js → timeline-definition-IT6M3QCI-C7ECznev.js} +1 -1
  54. package/dist-renderer/assets/{treemap-GDKQZRPO-BU0ha0Ww.js → treemap-GDKQZRPO-BRg3Zpk4.js} +1 -1
  55. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-BzAHNASi.js → xychartDiagram-PRI3JC2R-CoZGyc2f.js} +1 -1
  56. package/dist-renderer/index.html +2 -2
  57. package/package.json +23 -17
  58. package/src/main/server.ts +179 -23
  59. package/src/main/services/teams-mvp/TaskDispatchService.ts +440 -0
  60. package/src/main/services/teams-mvp/TeamProvisioningService.ts +36 -33
  61. package/src/main/services/teams-mvp/TeamWorkspaceService.ts +2 -0
  62. package/src/renderer/components/settings/SettingsTabs.tsx +8 -2
  63. package/src/renderer/components/settings/SettingsView.tsx +4 -0
  64. package/src/renderer/components/settings/sections/GeneralSection.tsx +168 -206
  65. package/src/renderer/components/settings/sections/TaskBusSection.tsx +176 -0
  66. package/src/renderer/components/sidebar/SidebarSessions.tsx +31 -4
  67. package/src/renderer/components/team/kanban/KanbanTaskCard.tsx +37 -0
  68. package/src/renderer/components/team/messages/MessageComposer.tsx +36 -228
  69. package/src/renderer/components/team/messages/MessagesPanel.tsx +0 -3
  70. package/src/renderer/store/slices/teamSlice.ts +30 -1
  71. package/src/shared/types/team.ts +73 -0
  72. package/dist-renderer/assets/channel-BSWYOYIc.js +0 -1
  73. package/dist-renderer/assets/classDiagram-2ON5EDUG-mw4yABob.js +0 -1
  74. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-mw4yABob.js +0 -1
  75. 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 + 注入 MCP 配置
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. 如果 harness 支持,注入 MCP 配置到 workDir
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
- if (MCP_AUTO_INJECT_HARNESS.has(manifest.harness)) {
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
- // MCP config injection
242
+ // CLAUDE.md instruction injection
251
243
  // ===========================================================================
252
244
 
253
- /**
254
- * workDir 注入 MCP 配置,让 claude code / qoder 自动发现 hermit-tasks 工具。
255
- * 写入 workDir/.claude/settings.json(claudecode)。
256
- */
257
- private async injectMcpConfig(workDir: string, teamSlug: string): Promise<void> {
258
- const settingsPath = path.join(workDir, '.claude', 'settings.json');
259
- try {
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
- let existing: Record<string, unknown> = {};
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
- const raw = await fs.promises.readFile(settingsPath, 'utf8');
265
- existing = JSON.parse(raw) as Record<string, unknown>;
271
+ existing = await fs.promises.readFile(mdPath, 'utf8');
266
272
  } catch {
267
- // file doesn't exist yet, start fresh
273
+ // File doesn't exist yet
268
274
  }
269
275
 
270
- const mcpServers = (existing.mcpServers as Record<string, unknown>) ?? {};
271
- mcpServers['hermit-tasks'] = {
272
- url: MCP_SERVER_URL,
273
- env: { HERMIT_TEAM_SLUG: teamSlug },
274
- };
276
+ if (existing.includes('Cross-Team Task Dispatch (Hermit)')) {
277
+ return;
278
+ }
275
279
 
276
- const updated = { ...existing, mcpServers };
277
- await fs.promises.writeFile(settingsPath, JSON.stringify(updated, null, 2), 'utf8');
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
- `MCP config injection failed (workDir=${workDir}): ${err instanceof Error ? err.message : String(err)}`
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}