feat: add cross-team communication orchestrator

Autonomous message routing between Agent Teams via MCP with cascade
protection. Inbox-first canonical delivery with cross-process file
lock (O_CREAT|O_EXCL) and best-effort relay for online teams.

- CascadeGuard: rate limit (10/min), chain depth (max 5), pair cooldown (3s)
- FileLock: cross-process safe via kernel-level atomic lock files
- CrossTeamService: validate → cascade → file-lock → inbox write → relay → outbox
- Unified lead resolver with members.meta.json normalization (trim+dedup)
- 3 MCP tools: cross_team_send, cross_team_list_targets, cross_team_get_outbox
- Controller module with sync file lock, same protocol as main
- IPC adapter with 3 Electron handlers
- 64 new tests across 8 test files
This commit is contained in:
iliya 2026-03-09 18:45:15 +02:00
parent afb6e2794f
commit c3eea4d6eb
31 changed files with 1992 additions and 46 deletions

View file

@ -5,6 +5,7 @@ const review = require('./internal/review.js');
const messages = require('./internal/messages.js');
const processes = require('./internal/processes.js');
const maintenance = require('./internal/maintenance.js');
const crossTeam = require('./internal/crossTeam.js');
function bindModule(context, moduleApi) {
return Object.fromEntries(
@ -26,6 +27,7 @@ function createController(options) {
messages: bindModule(context, messages),
processes: bindModule(context, processes),
maintenance: bindModule(context, maintenance),
crossTeam: bindModule(context, crossTeam),
};
}
@ -38,4 +40,5 @@ module.exports = {
messages,
processes,
maintenance,
crossTeam,
};

View file

@ -0,0 +1,59 @@
const MAX_PER_MINUTE = 10;
const PAIR_COOLDOWN_MS = 3000;
const MAX_CHAIN_DEPTH = 5;
const WINDOW_MS = 60000;
const teamCounters = new Map();
const pairTimestamps = new Map();
function cleanup(now) {
for (const [team, timestamps] of teamCounters) {
const fresh = timestamps.filter((ts) => ts > now - WINDOW_MS);
if (fresh.length === 0) {
teamCounters.delete(team);
} else {
teamCounters.set(team, fresh);
}
}
for (const [key, ts] of pairTimestamps) {
if (now - ts > WINDOW_MS) {
pairTimestamps.delete(key);
}
}
}
function check(fromTeam, toTeam, chainDepth) {
if (chainDepth >= MAX_CHAIN_DEPTH) {
throw new Error(`Cross-team chain depth limit exceeded (max ${MAX_CHAIN_DEPTH})`);
}
const now = Date.now();
cleanup(now);
const counts = teamCounters.get(fromTeam) || [];
const recentCount = counts.filter((ts) => ts > now - WINDOW_MS).length;
if (recentCount >= MAX_PER_MINUTE) {
throw new Error(`Cross-team rate limit exceeded for ${fromTeam} (max ${MAX_PER_MINUTE}/min)`);
}
const pairKey = `${fromTeam}\u2192${toTeam}`;
const lastPairTs = pairTimestamps.get(pairKey);
if (lastPairTs !== undefined && now - lastPairTs < PAIR_COOLDOWN_MS) {
throw new Error(`Cross-team pair cooldown active: ${fromTeam} \u2192 ${toTeam}`);
}
}
function record(fromTeam, toTeam) {
const now = Date.now();
const counts = teamCounters.get(fromTeam) || [];
counts.push(now);
teamCounters.set(fromTeam, counts);
pairTimestamps.set(`${fromTeam}\u2192${toTeam}`, now);
}
function reset() {
teamCounters.clear();
pairTimestamps.clear();
}
module.exports = { check, record, reset };

View file

@ -0,0 +1,225 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { createControllerContext } = require('./context.js');
const { withFileLockSync } = require('./fileLock.js');
const cascadeGuard = require('./cascadeGuard.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
function readJson(filePath, fallbackValue) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
if (error && error.code === 'ENOENT') return fallbackValue;
throw error;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
}
function normalizeMetaMembers(rawMembers) {
if (!Array.isArray(rawMembers)) return [];
const deduped = new Map();
for (const m of rawMembers) {
if (!m || typeof m !== 'object') continue;
const name = typeof m.name === 'string' ? m.name.trim() : '';
if (!name) continue;
deduped.set(name, {
name,
agentType: typeof m.agentType === 'string' ? m.agentType.trim() || undefined : undefined,
role: typeof m.role === 'string' ? m.role.trim() || undefined : undefined,
});
}
return Array.from(deduped.values());
}
function resolveTargetLead(paths, config) {
// 1. config.members — agentType check
if (config && config.members && config.members.length) {
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
if (lead && lead.name) return String(lead.name).trim();
// 2. config.members — name check
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
if (namedLead && namedLead.name) return String(namedLead.name).trim();
}
// 3. members.meta.json — WITH normalization (trim + dedup)
const metaPath = path.join(paths.teamDir, 'members.meta.json');
try {
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const members = normalizeMetaMembers(raw && raw.members);
if (members.length > 0) {
const metaLead = members.find(
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
);
if (metaLead && metaLead.name) return metaLead.name;
return members[0].name;
}
} catch {
/* ENOENT or parse error */
}
// 4. role-based (legacy compat)
if (config && config.members && config.members.length) {
const roleLead = config.members.find(
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
);
if (roleLead && roleLead.name) return String(roleLead.name).trim();
// 5. First member
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
}
return 'team-lead';
}
function createTargetContext(sourceContext, toTeam) {
return createControllerContext({
teamName: toTeam,
claudeDir: sourceContext.claudeDir,
});
}
function sendCrossTeamMessage(context, flags) {
const fromTeam = context.teamName;
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
const text = typeof flags.text === 'string' ? flags.text : '';
const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined;
const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0;
// Validate
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
throw new Error(`Invalid fromTeam: ${fromTeam}`);
}
if (!TEAM_NAME_PATTERN.test(toTeam)) {
throw new Error(`Invalid toTeam: ${toTeam}`);
}
if (fromTeam === toTeam) {
throw new Error('Cannot send cross-team message to the same team');
}
if (!text || text.trim().length === 0) {
throw new Error('Message text is required');
}
// Target context + config
const targetContext = createTargetContext(context, toTeam);
const targetConfig = runtimeHelpers.readTeamConfig(targetContext.paths);
if (!targetConfig || targetConfig.deletedAt) {
throw new Error(`Target team not found: ${toTeam}`);
}
// Resolve lead
const leadName = resolveTargetLead(targetContext.paths, targetConfig);
// Cascade check
cascadeGuard.check(fromTeam, toTeam, chainDepth);
cascadeGuard.record(fromTeam, toTeam);
// Format
const from = `${fromTeam}.${fromMember}`;
const formattedText = `[Cross-team from ${from} | depth:${chainDepth}]\n${text}`;
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
// Cross-process safe inbox write
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
withFileLockSync(inboxPath, () => {
fs.mkdirSync(path.dirname(inboxPath), { recursive: true });
const current = readJson(inboxPath, []);
const list = Array.isArray(current) ? current : [];
list.push({
from,
to: leadName,
text: formattedText,
timestamp: new Date().toISOString(),
read: false,
summary: summary || `Cross-team message from ${fromTeam}`,
messageId,
source: 'cross_team',
});
writeJson(inboxPath, list);
});
// Verify
const inbox = readJson(inboxPath, []);
if (!inbox.some((m) => m.messageId === messageId)) {
throw new Error('Cross-team inbox write verification failed');
}
// Record outbox (with file lock)
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
withFileLockSync(outboxPath, () => {
const outbox = readJson(outboxPath, []);
const outList = Array.isArray(outbox) ? outbox : [];
outList.push({
messageId,
fromTeam,
fromMember,
toTeam,
text,
summary,
chainDepth,
timestamp: new Date().toISOString(),
});
writeJson(outboxPath, outList);
});
return { messageId, deliveredToInbox: true };
}
function listCrossTeamTargets(context, flags) {
const excludeTeam =
typeof flags === 'object' && flags && typeof flags.excludeTeam === 'string'
? flags.excludeTeam
: context.teamName;
const teamsDir = path.dirname(context.paths.teamDir);
let entries;
try {
entries = fs.readdirSync(teamsDir);
} catch {
return [];
}
const targets = [];
for (const entry of entries) {
if (entry === excludeTeam) continue;
if (!TEAM_NAME_PATTERN.test(entry)) continue;
const entryTeamDir = path.join(teamsDir, entry);
const configPath = path.join(entryTeamDir, 'config.json');
let config;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch {
continue;
}
if (!config || config.deletedAt) continue;
targets.push({
teamName: entry,
displayName: config.name || entry,
description: config.description || undefined,
});
}
return targets;
}
function getCrossTeamOutbox(context) {
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
return readJson(outboxPath, []);
}
module.exports = {
sendCrossTeamMessage,
listCrossTeamTargets,
getCrossTeamOutbox,
};

View file

@ -0,0 +1,81 @@
const fs = require('fs');
const path = require('path');
const STALE_TIMEOUT_MS = 30000;
const ACQUIRE_TIMEOUT_MS = 5000;
const SPIN_INTERVAL_MS = 20;
function readLockAge(lockPath) {
try {
const content = fs.readFileSync(lockPath, 'utf8');
const ts = parseInt(content.split('\n')[1] || '', 10);
if (Number.isFinite(ts)) return Date.now() - ts;
} catch {
/* lock may have been released concurrently */
}
return null;
}
function tryAcquire(lockPath) {
try {
const dir = path.dirname(lockPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const fd = fs.openSync(lockPath, 'wx');
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
fs.closeSync(fd);
return true;
} catch (err) {
if (err && err.code === 'EEXIST') {
const age = readLockAge(lockPath);
if (age !== null && age > STALE_TIMEOUT_MS) {
try {
fs.unlinkSync(lockPath);
} catch {
/* another process may have cleaned it */
}
}
return false;
}
throw err;
}
}
function releaseLock(lockPath) {
try {
fs.unlinkSync(lockPath);
} catch {
/* already released or cleaned up */
}
}
function spinWait(deadlineMs) {
while (Date.now() < deadlineMs) {
const waitUntil = Date.now() + SPIN_INTERVAL_MS;
while (Date.now() < waitUntil) {
/* busy-wait — intentionally synchronous */
}
return;
}
}
function withFileLockSync(filePath, fn) {
const lockPath = `${filePath}.lock`;
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
while (!tryAcquire(lockPath)) {
if (Date.now() >= deadline) {
throw new Error(`File lock timeout: ${filePath}`);
}
spinWait(Math.min(Date.now() + SPIN_INTERVAL_MS, deadline));
}
try {
return fn();
} finally {
releaseLock(lockPath);
}
}
module.exports = { withFileLockSync };

View file

@ -0,0 +1,58 @@
const cascadeGuard = require('../src/internal/cascadeGuard.js');
describe('cascadeGuard', () => {
beforeEach(() => {
cascadeGuard.reset();
});
describe('rate limit', () => {
it('allows up to 10 messages per minute', () => {
for (let i = 0; i < 10; i++) {
cascadeGuard.check('team-a', `team-${i}`, 0);
cascadeGuard.record('team-a', `team-${i}`);
}
expect(() => cascadeGuard.check('team-a', 'team-x', 0)).toThrow('rate limit');
});
});
describe('chain depth', () => {
it('allows depth 0 through 4', () => {
for (let d = 0; d < 5; d++) {
expect(() => cascadeGuard.check('team-a', 'team-b', d)).not.toThrow();
}
});
it('rejects depth >= 5', () => {
expect(() => cascadeGuard.check('team-a', 'team-b', 5)).toThrow('chain depth');
});
});
describe('pair cooldown', () => {
it('rejects same pair within 3s', () => {
cascadeGuard.check('team-a', 'team-b', 0);
cascadeGuard.record('team-a', 'team-b');
expect(() => cascadeGuard.check('team-a', 'team-b', 0)).toThrow('cooldown');
});
it('allows different pairs simultaneously', () => {
cascadeGuard.check('team-a', 'team-b', 0);
cascadeGuard.record('team-a', 'team-b');
expect(() => cascadeGuard.check('team-a', 'team-c', 0)).not.toThrow();
});
});
describe('reset', () => {
it('clears all state', () => {
for (let i = 0; i < 10; i++) {
cascadeGuard.check('team-a', `team-${i}`, 0);
cascadeGuard.record('team-a', `team-${i}`);
}
cascadeGuard.reset();
expect(() => cascadeGuard.check('team-a', 'team-0', 0)).not.toThrow();
});
});
});

View file

@ -0,0 +1,277 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createController } = require('../src/index.js');
describe('crossTeam module', () => {
function makeClaudeDir(teams = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
for (const [teamName, config] of Object.entries(teams)) {
const teamDir = path.join(dir, 'teams', teamName);
const taskDir = path.join(dir, 'tasks', teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.mkdirSync(taskDir, { recursive: true });
fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify(config, null, 2)
);
}
return dir;
}
afterEach(() => {
// Reset cascade guard between tests
const cascadeGuard = require('../src/internal/cascadeGuard.js');
cascadeGuard.reset();
});
describe('sendCrossTeamMessage', () => {
it('delivers message to target team inbox', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
const result = controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
fromMember: 'lead',
text: 'Hello from team-a',
summary: 'Test message',
});
expect(result.deliveredToInbox).toBe(true);
expect(result.messageId).toBeDefined();
// Verify inbox was written
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(inbox).toHaveLength(1);
expect(inbox[0].source).toBe('cross_team');
expect(inbox[0].from).toBe('team-a.lead');
expect(inbox[0].text).toContain('[Cross-team from team-a.lead | depth:0]');
});
it('records outbox entry', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
});
const outbox = controller.crossTeam.getCrossTeamOutbox();
expect(outbox).toHaveLength(1);
expect(outbox[0].toTeam).toBe('team-b');
});
it('rejects self-send', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
expect(() =>
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-a',
text: 'Self',
})
).toThrow('same team');
});
it('rejects when target not found', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
expect(() =>
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-nonexistent',
text: 'Hello',
})
).toThrow('Target team not found');
});
it('rejects when target is deleted', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
deletedAt: '2024-01-01T00:00:00Z',
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
expect(() =>
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
})
).toThrow('Target team not found');
});
it('rejects excessive chain depth', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
expect(() =>
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
chainDepth: 5,
})
).toThrow('chain depth');
});
});
describe('resolveTargetLead', () => {
it('resolves lead by agentType', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'alpha-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [{ name: 'beta-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
});
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'beta-lead.json');
expect(fs.existsSync(inboxPath)).toBe(true);
});
it('resolves lead by name fallback', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [{ name: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
});
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
expect(fs.existsSync(inboxPath)).toBe(true);
});
it('resolves lead from members.meta.json with normalization', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [],
},
});
// Write meta with dirty data (leading spaces, duplicates)
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
fs.writeFileSync(
metaPath,
JSON.stringify({
members: [
{ name: ' meta-lead ', agentType: 'team-lead' },
{ name: ' meta-lead ', agentType: 'team-lead' },
{ name: 'worker', agentType: 'worker' },
],
})
);
const controller = createController({ teamName: 'team-a', claudeDir });
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
});
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'meta-lead.json');
expect(fs.existsSync(inboxPath)).toBe(true);
});
});
describe('listCrossTeamTargets', () => {
it('lists valid teams excluding current', () => {
const claudeDir = makeClaudeDir({
'team-a': { name: 'Team A' },
'team-b': { name: 'Team B', description: 'B desc' },
'team-c': { name: 'Team C', deletedAt: '2024-01-01' },
});
const controller = createController({ teamName: 'team-a', claudeDir });
const targets = controller.crossTeam.listCrossTeamTargets();
expect(targets).toHaveLength(1);
expect(targets[0].teamName).toBe('team-b');
expect(targets[0].displayName).toBe('Team B');
expect(targets[0].description).toBe('B desc');
});
});
describe('getCrossTeamOutbox', () => {
it('returns empty for non-existent outbox', () => {
const claudeDir = makeClaudeDir({
'team-a': { name: 'Team A' },
});
const controller = createController({ teamName: 'team-a', claudeDir });
const outbox = controller.crossTeam.getCrossTeamOutbox();
expect(outbox).toEqual([]);
});
});
});

View file

@ -60,6 +60,12 @@ declare module 'agent-teams-controller' {
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
}
export interface ControllerCrossTeamApi {
sendCrossTeamMessage(flags: Record<string, unknown>): unknown;
listCrossTeamTargets(flags?: Record<string, unknown>): unknown;
getCrossTeamOutbox(): unknown;
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
@ -67,6 +73,7 @@ declare module 'agent-teams-controller' {
messages: ControllerMessageApi;
processes: ControllerProcessApi;
maintenance: ControllerMaintenanceApi;
crossTeam: ControllerCrossTeamApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;

View file

@ -0,0 +1,67 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'cross_team_send',
description:
'Send a message to another team. The message is delivered to the target team lead inbox.',
parameters: z.object({
...toolContextSchema,
toTeam: z.string().min(1),
text: z.string().min(1),
fromMember: z.string().optional(),
summary: z.string().optional(),
chainDepth: z.number().int().nonnegative().optional(),
}),
execute: async ({ teamName, claudeDir, toTeam, text, fromMember, summary, chainDepth }) =>
await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
toTeam,
text,
...(fromMember ? { fromMember } : {}),
...(summary ? { summary } : {}),
...(chainDepth !== undefined ? { chainDepth } : {}),
})
)
),
});
server.addTool({
name: 'cross_team_list_targets',
description: 'List available teams that can receive cross-team messages.',
parameters: z.object({
...toolContextSchema,
excludeTeam: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, excludeTeam }) =>
await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
...(excludeTeam ? { excludeTeam } : {}),
})
)
),
});
server.addTool({
name: 'cross_team_get_outbox',
description: 'Get sent cross-team messages for the current team.',
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
),
});
}

View file

@ -1,5 +1,6 @@
import type { FastMCP } from 'fastmcp';
import { registerCrossTeamTools } from './crossTeamTools';
import { registerKanbanTools } from './kanbanTools';
import { registerMessageTools } from './messageTools';
import { registerProcessTools } from './processTools';
@ -12,4 +13,5 @@ export function registerTools(server: FastMCP) {
registerReviewTools(server);
registerMessageTools(server);
registerProcessTools(server);
registerCrossTeamTools(server);
}

View file

@ -30,6 +30,9 @@ function parseJsonToolResult(result: unknown) {
describe('agent-teams-mcp tools', () => {
const tools = collectTools();
const expectedToolNames = [
'cross_team_get_outbox',
'cross_team_list_targets',
'cross_team_send',
'kanban_add_reviewer',
'kanban_clear',
'kanban_get',

View file

@ -16,6 +16,9 @@
// On Windows this saturates all threads, blocking the event loop.
process.env.UV_THREADPOOL_SIZE ??= '16';
import { CrossTeamService } from '@main/services/team/CrossTeamService';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { FileContentResolver } from '@main/services/team/FileContentResolver';
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
@ -663,6 +666,17 @@ function initializeServices(): void {
ptyTerminalService = new PtyTerminalService();
teamDataService = new TeamDataService();
teamProvisioningService = new TeamProvisioningService();
// Cross-team communication service
const crossTeamConfigReader = new TeamConfigReader();
const crossTeamInboxWriter = new TeamInboxWriter();
const crossTeamService = new CrossTeamService(
crossTeamConfigReader,
teamDataService,
crossTeamInboxWriter,
teamProvisioningService
);
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
const taskBoundaryParser = new TaskBoundaryParser();
@ -760,7 +774,8 @@ function initializeServices(): void {
schedulerService,
extensionFacadeService,
pluginInstallService,
mcpInstallService
mcpInstallService,
crossTeamService
);
// Forward SSH state changes to renderer and HTTP SSE clients

93
src/main/ipc/crossTeam.ts Normal file
View file

@ -0,0 +1,93 @@
import {
CROSS_TEAM_GET_OUTBOX,
CROSS_TEAM_LIST_TARGETS,
CROSS_TEAM_SEND,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import type { CrossTeamService } from '../services/team/CrossTeamService';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
import type { IpcResult } from '@shared/types';
const logger = createLogger('IPC:crossTeam');
let crossTeamService: CrossTeamService | null = null;
export function initializeCrossTeamHandlers(service: CrossTeamService): void {
crossTeamService = service;
}
function getService(): CrossTeamService {
if (!crossTeamService) {
throw new Error('CrossTeamService not initialized');
}
return crossTeamService;
}
async function wrapCrossTeamHandler<T>(
operation: string,
handler: () => Promise<T>
): Promise<IpcResult<T>> {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[crossTeam:${operation}] ${message}`);
return { success: false, error: message };
}
}
async function handleSend(
_event: IpcMainInvokeEvent,
request: unknown
): Promise<IpcResult<unknown>> {
return wrapCrossTeamHandler('send', () => {
if (!request || typeof request !== 'object') {
throw new Error('Invalid request');
}
const req = request as Record<string, unknown>;
return getService().send({
fromTeam: String(req.fromTeam ?? ''),
fromMember: String(req.fromMember ?? ''),
toTeam: String(req.toTeam ?? ''),
text: String(req.text ?? ''),
summary: typeof req.summary === 'string' ? req.summary : undefined,
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
});
});
}
async function handleListTargets(
_event: IpcMainInvokeEvent,
excludeTeam?: string
): Promise<IpcResult<unknown>> {
return wrapCrossTeamHandler('listTargets', () =>
getService().listAvailableTargets(typeof excludeTeam === 'string' ? excludeTeam : undefined)
);
}
async function handleGetOutbox(
_event: IpcMainInvokeEvent,
teamName: string
): Promise<IpcResult<unknown>> {
return wrapCrossTeamHandler('getOutbox', () => {
if (typeof teamName !== 'string' || !teamName.trim()) {
throw new Error('teamName is required');
}
return getService().getOutbox(teamName);
});
}
export function registerCrossTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(CROSS_TEAM_SEND, handleSend);
ipcMain.handle(CROSS_TEAM_LIST_TARGETS, handleListTargets);
ipcMain.handle(CROSS_TEAM_GET_OUTBOX, handleGetOutbox);
}
export function removeCrossTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(CROSS_TEAM_SEND);
ipcMain.removeHandler(CROSS_TEAM_LIST_TARGETS);
ipcMain.removeHandler(CROSS_TEAM_GET_OUTBOX);
}

View file

@ -28,6 +28,11 @@ import {
registerContextHandlers,
removeContextHandlers,
} from './context';
import {
initializeCrossTeamHandlers,
registerCrossTeamHandlers,
removeCrossTeamHandlers,
} from './crossTeam';
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
import {
initializeExtensionHandlers,
@ -98,6 +103,7 @@ import type {
UpdaterService,
} from '../services';
import type { HttpServer } from '../services/infrastructure/HttpServer';
import type { CrossTeamService } from '../services/team/CrossTeamService';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
@ -132,7 +138,8 @@ export function initializeIpcHandlers(
schedulerService?: SchedulerService,
extensionFacade?: ExtensionFacadeService,
pluginInstaller?: PluginInstallService,
mcpInstaller?: McpInstallService
mcpInstaller?: McpInstallService,
crossTeamService?: CrossTeamService
): void {
// Initialize domain handlers with registry
initializeProjectHandlers(registry);
@ -170,6 +177,9 @@ export function initializeIpcHandlers(
if (extensionFacade) {
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller);
}
if (crossTeamService) {
initializeCrossTeamHandlers(crossTeamService);
}
if (changeExtractor) {
initializeReviewHandlers({
@ -210,6 +220,9 @@ export function initializeIpcHandlers(
if (extensionFacade) {
registerExtensionHandlers(ipcMain);
}
if (crossTeamService) {
registerCrossTeamHandlers(ipcMain);
}
logger.info('All handlers registered');
}
@ -240,6 +253,7 @@ export function removeIpcHandlers(): void {
removeTerminalHandlers(ipcMain);
removeHttpServerHandlers(ipcMain);
removeExtensionHandlers(ipcMain);
removeCrossTeamHandlers(ipcMain);
logger.info('All handlers removed');
}

View file

@ -0,0 +1,59 @@
const MAX_PER_MINUTE = 10;
const PAIR_COOLDOWN_MS = 3_000;
const MAX_CHAIN_DEPTH = 5;
const WINDOW_MS = 60_000;
export class CascadeGuard {
private teamCounters = new Map<string, number[]>();
private pairTimestamps = new Map<string, number>();
check(fromTeam: string, toTeam: string, chainDepth: number): void {
if (chainDepth >= MAX_CHAIN_DEPTH) {
throw new Error(`Cross-team chain depth limit exceeded (max ${MAX_CHAIN_DEPTH})`);
}
const now = Date.now();
this.cleanup(now);
const counts = this.teamCounters.get(fromTeam) ?? [];
const recentCount = counts.filter((ts) => ts > now - WINDOW_MS).length;
if (recentCount >= MAX_PER_MINUTE) {
throw new Error(`Cross-team rate limit exceeded for ${fromTeam} (max ${MAX_PER_MINUTE}/min)`);
}
const pairKey = `${fromTeam}${toTeam}`;
const lastPairTs = this.pairTimestamps.get(pairKey);
if (lastPairTs !== undefined && now - lastPairTs < PAIR_COOLDOWN_MS) {
throw new Error(`Cross-team pair cooldown active: ${fromTeam}${toTeam}`);
}
}
record(fromTeam: string, toTeam: string): void {
const now = Date.now();
const counts = this.teamCounters.get(fromTeam) ?? [];
counts.push(now);
this.teamCounters.set(fromTeam, counts);
this.pairTimestamps.set(`${fromTeam}${toTeam}`, now);
}
reset(): void {
this.teamCounters.clear();
this.pairTimestamps.clear();
}
private cleanup(now: number): void {
for (const [team, timestamps] of this.teamCounters) {
const fresh = timestamps.filter((ts) => ts > now - WINDOW_MS);
if (fresh.length === 0) {
this.teamCounters.delete(team);
} else {
this.teamCounters.set(team, fresh);
}
}
for (const [key, ts] of this.pairTimestamps) {
if (now - ts > WINDOW_MS) {
this.pairTimestamps.delete(key);
}
}
}
}

View file

@ -0,0 +1,45 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { withFileLock } from './fileLock';
import type { CrossTeamMessage } from '@shared/types';
export class CrossTeamOutbox {
private getOutboxPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'sent-cross-team.json');
}
async append(teamName: string, message: CrossTeamMessage): Promise<void> {
const outboxPath = this.getOutboxPath(teamName);
await withFileLock(outboxPath, async () => {
let list: CrossTeamMessage[] = [];
try {
const raw = await fs.promises.readFile(outboxPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
list = parsed as CrossTeamMessage[];
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
list.push(message);
const dir = path.dirname(outboxPath);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(outboxPath, JSON.stringify(list, null, 2), 'utf8');
});
}
async read(teamName: string): Promise<CrossTeamMessage[]> {
const outboxPath = this.getOutboxPath(teamName);
try {
const raw = await fs.promises.readFile(outboxPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as CrossTeamMessage[]) : [];
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
}
}

View file

@ -0,0 +1,147 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import { CascadeGuard } from './CascadeGuard';
import { CrossTeamOutbox } from './CrossTeamOutbox';
import type { TeamConfigReader } from './TeamConfigReader';
import type { TeamDataService } from './TeamDataService';
import type { TeamInboxWriter } from './TeamInboxWriter';
import type { TeamProvisioningService } from './TeamProvisioningService';
import type {
CrossTeamMessage,
CrossTeamSendRequest,
CrossTeamSendResult,
TeamConfig,
} from '@shared/types';
const logger = createLogger('CrossTeamService');
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
export interface CrossTeamTarget {
teamName: string;
displayName: string;
description?: string;
}
export class CrossTeamService {
private cascadeGuard = new CascadeGuard();
private outbox = new CrossTeamOutbox();
constructor(
private configReader: TeamConfigReader,
private dataService: TeamDataService,
private inboxWriter: TeamInboxWriter,
private provisioning: TeamProvisioningService | null
) {}
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
const { fromTeam, fromMember, toTeam, text, summary } = request;
const chainDepth = request.chainDepth ?? 0;
// 1. Validate
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
throw new Error(`Invalid fromTeam: ${fromTeam}`);
}
if (!TEAM_NAME_PATTERN.test(toTeam)) {
throw new Error(`Invalid toTeam: ${toTeam}`);
}
if (fromTeam === toTeam) {
throw new Error('Cannot send cross-team message to the same team');
}
if (!text || typeof text !== 'string' || text.trim().length === 0) {
throw new Error('Message text is required');
}
const targetConfig = await this.configReader.getConfig(toTeam);
if (!targetConfig || targetConfig.deletedAt) {
throw new Error(`Target team not found: ${toTeam}`);
}
// 2. Resolve lead
const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead';
// 3. Cascade check
this.cascadeGuard.check(fromTeam, toTeam, chainDepth);
this.cascadeGuard.record(fromTeam, toTeam);
// 4. Format
const from = `${fromTeam}.${fromMember}`;
const formattedText = `[Cross-team from ${from} | depth:${chainDepth}]\n${text}`;
const messageId = randomUUID();
// 5. Inbox write (TeamInboxWriter handles file lock + in-process lock internally)
await this.inboxWriter.sendMessage(toTeam, {
member: leadName,
text: formattedText,
from,
summary: summary ?? `Cross-team message from ${fromTeam}`,
source: 'cross_team',
});
// 6. Best-effort relay (if online)
if (this.provisioning?.isTeamAlive(toTeam)) {
void this.provisioning.relayLeadInboxMessages(toTeam).catch((e: unknown) => {
logger.warn(`Cross-team relay to ${toTeam}: ${e instanceof Error ? e.message : String(e)}`);
});
}
// 7. Record outbox
const outboxMessage: CrossTeamMessage = {
messageId,
fromTeam,
fromMember,
toTeam,
text,
summary,
chainDepth,
timestamp: new Date().toISOString(),
};
void this.outbox.append(fromTeam, outboxMessage).catch((e: unknown) => {
logger.warn(
`Failed to write outbox for ${fromTeam}: ${e instanceof Error ? e.message : String(e)}`
);
});
return { messageId, deliveredToInbox: true };
}
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
const teamsDir = getTeamsBasePath();
let entries: string[];
try {
entries = await fs.promises.readdir(teamsDir);
} catch {
return [];
}
const targets: CrossTeamTarget[] = [];
for (const entry of entries) {
if (excludeTeam && entry === excludeTeam) continue;
if (!TEAM_NAME_PATTERN.test(entry)) continue;
let config: TeamConfig | null;
try {
config = await this.configReader.getConfig(entry);
} catch {
continue;
}
if (!config || config.deletedAt) continue;
targets.push({
teamName: entry,
displayName: config.name || entry,
description: config.description,
});
}
return targets;
}
async getOutbox(teamName: string): Promise<CrossTeamMessage[]> {
return this.outbox.read(teamName);
}
}

View file

@ -4,6 +4,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import { withFileLock } from './fileLock';
import { withInboxLock } from './inboxLock';
import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types';
@ -33,18 +34,20 @@ export class TeamInboxWriter {
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
};
await withInboxLock(inboxPath, async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const list = await this.readInbox(inboxPath);
list.push(payload);
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
const written = await this.readInbox(inboxPath);
if (written.some((msg) => msg.messageId === messageId)) {
return;
await withFileLock(inboxPath, async () => {
await withInboxLock(inboxPath, async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const list = await this.readInbox(inboxPath);
list.push(payload);
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
const written = await this.readInbox(inboxPath);
if (written.some((msg) => msg.messageId === messageId)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
}
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
}
throw new Error('Failed to verify inbox write');
throw new Error('Failed to verify inbox write');
});
});
return {

View file

@ -37,6 +37,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { withFileLock } from './fileLock';
import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
@ -2858,40 +2859,42 @@ export class TeamProvisioningService {
): Promise<void> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
await withInboxLock(inboxPath, async () => {
const raw = await tryReadRegularFileUtf8(inboxPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_INBOX_MAX_BYTES,
});
if (!raw) {
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return;
}
if (!Array.isArray(parsed)) return;
const ids = new Set(messages.map((m) => m.messageId).filter((id) => id.trim().length > 0));
let changed = false;
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const msgId = typeof row.messageId === 'string' ? row.messageId : null;
if (!msgId || !ids.has(msgId)) continue;
if (row.read !== true) {
row.read = true;
changed = true;
await withFileLock(inboxPath, async () => {
await withInboxLock(inboxPath, async () => {
const raw = await tryReadRegularFileUtf8(inboxPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_INBOX_MAX_BYTES,
});
if (!raw) {
return;
}
}
if (!changed) return;
await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2));
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return;
}
if (!Array.isArray(parsed)) return;
const ids = new Set(messages.map((m) => m.messageId).filter((id) => id.trim().length > 0));
let changed = false;
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const msgId = typeof row.messageId === 'string' ? row.messageId : null;
if (!msgId || !ids.has(msgId)) continue;
if (row.read !== true) {
row.read = true;
changed = true;
}
}
if (!changed) return;
await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2));
});
});
}

View file

@ -0,0 +1,69 @@
import * as fs from 'fs';
import * as path from 'path';
const STALE_TIMEOUT_MS = 30_000;
const ACQUIRE_TIMEOUT_MS = 5_000;
const RETRY_INTERVAL_MS = 20;
function readLockAge(lockPath: string): number | null {
try {
const content = fs.readFileSync(lockPath, 'utf8');
const ts = parseInt(content.split('\n')[1] ?? '', 10);
if (Number.isFinite(ts)) return Date.now() - ts;
} catch {
/* lock may have been released concurrently */
}
return null;
}
function tryAcquire(lockPath: string): boolean {
try {
const dir = path.dirname(lockPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const fd = fs.openSync(lockPath, 'wx');
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
fs.closeSync(fd);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
const age = readLockAge(lockPath);
if (age !== null && age > STALE_TIMEOUT_MS) {
try {
fs.unlinkSync(lockPath);
} catch {
/* another process may have cleaned it */
}
}
return false;
}
throw err;
}
}
function releaseLock(lockPath: string): void {
try {
fs.unlinkSync(lockPath);
} catch {
/* already released or cleaned up */
}
}
export async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const lockPath = `${filePath}.lock`;
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
while (!tryAcquire(lockPath)) {
if (Date.now() >= deadline) {
throw new Error(`File lock timeout: ${filePath}`);
}
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
}
try {
return await fn();
} finally {
releaseLock(lockPath);
}
}

View file

@ -1,5 +1,8 @@
export { CascadeGuard } from './CascadeGuard';
export { ChangeExtractorService } from './ChangeExtractorService';
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
export { CrossTeamOutbox } from './CrossTeamOutbox';
export { CrossTeamService } from './CrossTeamService';
export { FileContentResolver } from './FileContentResolver';
export { GitDiffFallback } from './GitDiffFallback';
export { HunkSnippetMatcher } from './HunkSnippetMatcher';

View file

@ -370,6 +370,19 @@ export const TEAM_VALIDATE_CLI_ARGS = 'team:validateCliArgs';
/** Invoke: update tool approval settings (renderer → main) */
export const TEAM_TOOL_APPROVAL_SETTINGS = 'team:toolApprovalSettings';
// =============================================================================
// Cross-Team Communication Channels
// =============================================================================
/** Send cross-team message */
export const CROSS_TEAM_SEND = 'crossTeam:send';
/** List available cross-team targets */
export const CROSS_TEAM_LIST_TARGETS = 'crossTeam:listTargets';
/** Get cross-team outbox for a team */
export const CROSS_TEAM_GET_OUTBOX = 'crossTeam:getOutbox';
// =============================================================================
// CLI Installer API Channels
// =============================================================================

View file

@ -10,6 +10,9 @@ import {
CONTEXT_GET_ACTIVE,
CONTEXT_LIST,
CONTEXT_SWITCH,
CROSS_TEAM_GET_OUTBOX,
CROSS_TEAM_LIST_TARGETS,
CROSS_TEAM_SEND,
EDITOR_CHANGE,
EDITOR_CLOSE,
EDITOR_CREATE_DIR,
@ -198,6 +201,9 @@ import type {
ContextInfo,
CreateScheduleInput,
CreateTaskRequest,
CrossTeamMessage,
CrossTeamSendRequest,
CrossTeamSendResult,
ElectronAPI,
FileChangeWithContent,
GlobalTask,
@ -1054,6 +1060,20 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<void>(TEAM_TOOL_APPROVAL_SETTINGS, settings);
},
},
crossTeam: {
send: async (request: CrossTeamSendRequest) => {
return invokeIpcWithResult<CrossTeamSendResult>(CROSS_TEAM_SEND, request);
},
listTargets: async (excludeTeam?: string) => {
return invokeIpcWithResult<{ teamName: string; displayName: string; description?: string }[]>(
CROSS_TEAM_LIST_TARGETS,
excludeTeam
);
},
getOutbox: async (teamName: string) => {
return invokeIpcWithResult<CrossTeamMessage[]>(CROSS_TEAM_GET_OUTBOX, teamName);
},
},
review: {
getAgentChanges: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName);

View file

@ -17,6 +17,7 @@ import type {
ContextInfo,
ConversationGroup,
CreateTaskRequest,
CrossTeamAPI,
ElectronAPI,
FileChangeEvent,
GlobalTask,
@ -910,6 +911,21 @@ export class HttpAPIClient implements ElectronAPI {
},
};
// Cross-team communication API stubs
crossTeam: CrossTeamAPI = {
send: async () => {
throw new Error('Cross-team communication is not available in browser mode');
},
listTargets: async () => {
console.warn('[HttpAPIClient] crossTeam.listTargets is not available in browser mode');
return [];
},
getOutbox: async () => {
console.warn('[HttpAPIClient] crossTeam.getOutbox is not available in browser mode');
return [];
},
};
// Review API stubs
review = {
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {

View file

@ -41,6 +41,9 @@ import type {
AttachmentFileData,
CommentAttachmentPayload,
CreateTaskRequest,
CrossTeamMessage,
CrossTeamSendRequest,
CrossTeamSendResult,
GlobalTask,
KanbanColumnId,
LeadActivityState,
@ -533,6 +536,18 @@ export interface TeamsAPI {
updateToolApprovalSettings: (settings: ToolApprovalSettings) => Promise<void>;
}
// =============================================================================
// Cross-Team Communication API
// =============================================================================
export interface CrossTeamAPI {
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
listTargets: (
excludeTeam?: string
) => Promise<{ teamName: string; displayName: string; description?: string }[]>;
getOutbox: (teamName: string) => Promise<CrossTeamMessage[]>;
}
// =============================================================================
// Schedule API
// =============================================================================
@ -761,6 +776,9 @@ export interface ElectronAPI {
// Team management API
teams: TeamsAPI;
// Cross-Team Communication API
crossTeam: CrossTeamAPI;
// Review API
review: ReviewAPI;

View file

@ -245,7 +245,13 @@ export interface InboxMessage {
summary?: string;
color?: string;
messageId?: string;
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent' | 'system_notification';
source?:
| 'inbox'
| 'lead_session'
| 'lead_process'
| 'user_sent'
| 'system_notification'
| 'cross_team';
attachments?: AttachmentMeta[];
/** Lead session ID that produced this message (for session boundary detection). */
leadSessionId?: string;
@ -590,6 +596,35 @@ export interface TeamMessageNotificationData {
suppressToast?: boolean;
}
// =============================================================================
// Cross-Team Communication
// =============================================================================
export interface CrossTeamMessage {
messageId: string;
fromTeam: string;
fromMember: string;
toTeam: string;
text: string;
summary?: string;
chainDepth: number;
timestamp: string;
}
export interface CrossTeamSendRequest {
fromTeam: string;
fromMember: string;
toTeam: string;
text: string;
summary?: string;
chainDepth?: number;
}
export interface CrossTeamSendResult {
messageId: string;
deliveredToInbox: boolean;
}
// =============================================================================
// Tool Approval (control_request / control_response protocol)
// =============================================================================

View file

@ -60,6 +60,12 @@ declare module 'agent-teams-controller' {
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
}
export interface ControllerCrossTeamApi {
sendCrossTeamMessage(flags: Record<string, unknown>): unknown;
listCrossTeamTargets(flags?: Record<string, unknown>): unknown;
getCrossTeamOutbox(): unknown;
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
@ -67,6 +73,7 @@ declare module 'agent-teams-controller' {
messages: ControllerMessageApi;
processes: ControllerProcessApi;
maintenance: ControllerMaintenanceApi;
crossTeam: ControllerCrossTeamApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;

View file

@ -0,0 +1,163 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
vi.mock('@preload/constants/ipcChannels', () => ({
CROSS_TEAM_SEND: 'cross-team:send',
CROSS_TEAM_LIST_TARGETS: 'cross-team:listTargets',
CROSS_TEAM_GET_OUTBOX: 'cross-team:getOutbox',
}));
import {
initializeCrossTeamHandlers,
registerCrossTeamHandlers,
removeCrossTeamHandlers,
} from '@main/ipc/crossTeam';
function createMockIpcMain() {
return {
handle: vi.fn(),
removeHandler: vi.fn(),
};
}
function createMockService() {
return {
send: vi.fn(),
listAvailableTargets: vi.fn(),
getOutbox: vi.fn(),
};
}
describe('crossTeam IPC handlers', () => {
let mockIpc: ReturnType<typeof createMockIpcMain>;
let mockService: ReturnType<typeof createMockService>;
beforeEach(() => {
mockIpc = createMockIpcMain();
mockService = createMockService();
// Re-initialize with fresh service for each test
initializeCrossTeamHandlers(mockService as never);
});
it('registers 3 IPC handlers', () => {
registerCrossTeamHandlers(mockIpc as never);
expect(mockIpc.handle).toHaveBeenCalledTimes(3);
expect(mockIpc.handle).toHaveBeenCalledWith('cross-team:send', expect.any(Function));
expect(mockIpc.handle).toHaveBeenCalledWith('cross-team:listTargets', expect.any(Function));
expect(mockIpc.handle).toHaveBeenCalledWith('cross-team:getOutbox', expect.any(Function));
});
it('removes all 3 handlers', () => {
removeCrossTeamHandlers(mockIpc as never);
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(3);
expect(mockIpc.removeHandler).toHaveBeenCalledWith('cross-team:send');
expect(mockIpc.removeHandler).toHaveBeenCalledWith('cross-team:listTargets');
expect(mockIpc.removeHandler).toHaveBeenCalledWith('cross-team:getOutbox');
});
it('send handler returns success on valid request', async () => {
mockService.send.mockResolvedValue({ messageId: 'msg-1', deliveredToInbox: true });
registerCrossTeamHandlers(mockIpc as never);
const handler = mockIpc.handle.mock.calls.find((c) => c[0] === 'cross-team:send')![1];
const result = await handler({} as never, {
fromTeam: 'team-a',
fromMember: 'lead',
toTeam: 'team-b',
text: 'Hello',
});
expect(result).toEqual({
success: true,
data: { messageId: 'msg-1', deliveredToInbox: true },
});
expect(mockService.send).toHaveBeenCalledWith({
fromTeam: 'team-a',
fromMember: 'lead',
toTeam: 'team-b',
text: 'Hello',
summary: undefined,
chainDepth: undefined,
});
});
it('send handler returns error on service throw', async () => {
mockService.send.mockRejectedValue(new Error('Target team not found'));
registerCrossTeamHandlers(mockIpc as never);
const handler = mockIpc.handle.mock.calls.find((c) => c[0] === 'cross-team:send')![1];
const result = await handler({} as never, {
fromTeam: 'team-a',
fromMember: 'lead',
toTeam: 'nonexistent',
text: 'Hello',
});
expect(result).toEqual({ success: false, error: 'Target team not found' });
});
it('send handler rejects invalid request', async () => {
registerCrossTeamHandlers(mockIpc as never);
const handler = mockIpc.handle.mock.calls.find((c) => c[0] === 'cross-team:send')![1];
const result = await handler({} as never, null);
expect(result).toEqual({ success: false, error: 'Invalid request' });
});
it('listTargets handler calls service', async () => {
mockService.listAvailableTargets.mockResolvedValue([
{ teamName: 'team-b', displayName: 'Team B' },
]);
registerCrossTeamHandlers(mockIpc as never);
const handler = mockIpc.handle.mock.calls.find(
(c) => c[0] === 'cross-team:listTargets'
)![1];
const result = await handler({} as never, 'team-a');
expect(result).toEqual({
success: true,
data: [{ teamName: 'team-b', displayName: 'Team B' }],
});
expect(mockService.listAvailableTargets).toHaveBeenCalledWith('team-a');
});
it('getOutbox handler calls service', async () => {
mockService.getOutbox.mockResolvedValue([]);
registerCrossTeamHandlers(mockIpc as never);
const handler = mockIpc.handle.mock.calls.find(
(c) => c[0] === 'cross-team:getOutbox'
)![1];
const result = await handler({} as never, 'team-a');
expect(result).toEqual({ success: true, data: [] });
expect(mockService.getOutbox).toHaveBeenCalledWith('team-a');
});
it('getOutbox handler rejects empty teamName', async () => {
registerCrossTeamHandlers(mockIpc as never);
const handler = mockIpc.handle.mock.calls.find(
(c) => c[0] === 'cross-team:getOutbox'
)![1];
const result = await handler({} as never, '');
expect(result).toEqual({ success: false, error: 'teamName is required' });
});
});

View file

@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CascadeGuard } from '@main/services/team/CascadeGuard';
describe('CascadeGuard', () => {
let guard: CascadeGuard;
beforeEach(() => {
guard = new CascadeGuard();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rate limit', () => {
it('allows up to 10 messages per minute', () => {
for (let i = 0; i < 10; i++) {
guard.check('team-a', `team-${i}`, 0);
guard.record('team-a', `team-${i}`);
}
// 11th should fail
expect(() => guard.check('team-a', 'team-x', 0)).toThrow('rate limit');
});
it('resets after window expires', () => {
vi.useFakeTimers();
for (let i = 0; i < 10; i++) {
guard.check('team-a', `team-${i}`, 0);
guard.record('team-a', `team-${i}`);
}
// Advance 61 seconds
vi.advanceTimersByTime(61_000);
// Should succeed now
expect(() => guard.check('team-a', 'team-new', 0)).not.toThrow();
vi.useRealTimers();
});
});
describe('chain depth', () => {
it('allows depth 0 through 4', () => {
for (let d = 0; d < 5; d++) {
expect(() => guard.check('team-a', 'team-b', d)).not.toThrow();
}
});
it('rejects depth >= 5', () => {
expect(() => guard.check('team-a', 'team-b', 5)).toThrow('chain depth');
expect(() => guard.check('team-a', 'team-b', 10)).toThrow('chain depth');
});
});
describe('pair cooldown', () => {
it('rejects same pair within 3s', () => {
guard.check('team-a', 'team-b', 0);
guard.record('team-a', 'team-b');
expect(() => guard.check('team-a', 'team-b', 0)).toThrow('cooldown');
});
it('allows same pair after 3s', () => {
vi.useFakeTimers();
guard.check('team-a', 'team-b', 0);
guard.record('team-a', 'team-b');
vi.advanceTimersByTime(3_001);
expect(() => guard.check('team-a', 'team-b', 0)).not.toThrow();
vi.useRealTimers();
});
it('allows different pairs simultaneously', () => {
guard.check('team-a', 'team-b', 0);
guard.record('team-a', 'team-b');
expect(() => guard.check('team-a', 'team-c', 0)).not.toThrow();
});
});
describe('reset', () => {
it('clears all state', () => {
for (let i = 0; i < 10; i++) {
guard.check('team-a', `team-${i}`, 0);
guard.record('team-a', `team-${i}`);
}
guard.reset();
expect(() => guard.check('team-a', 'team-0', 0)).not.toThrow();
});
});
});

View file

@ -0,0 +1,67 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CrossTeamOutbox } from '@main/services/team/CrossTeamOutbox';
import type { CrossTeamMessage } from '@shared/types';
vi.mock('@main/utils/pathDecoder', () => ({
getTeamsBasePath: () => tmpDir,
}));
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'outbox-test-'));
fs.mkdirSync(path.join(tmpDir, 'test-team'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMessage(overrides: Partial<CrossTeamMessage> = {}): CrossTeamMessage {
return {
messageId: 'msg-1',
fromTeam: 'team-a',
fromMember: 'lead',
toTeam: 'team-b',
text: 'hello',
chainDepth: 0,
timestamp: new Date().toISOString(),
...overrides,
};
}
describe('CrossTeamOutbox', () => {
let outbox: CrossTeamOutbox;
beforeEach(() => {
outbox = new CrossTeamOutbox();
});
it('returns empty array when no outbox file exists', async () => {
const result = await outbox.read('test-team');
expect(result).toEqual([]);
});
it('appends a message and reads it back', async () => {
const msg = makeMessage();
await outbox.append('test-team', msg);
const result = await outbox.read('test-team');
expect(result).toHaveLength(1);
expect(result[0].messageId).toBe('msg-1');
expect(result[0].fromTeam).toBe('team-a');
});
it('appends multiple messages', async () => {
await outbox.append('test-team', makeMessage({ messageId: 'msg-1' }));
await outbox.append('test-team', makeMessage({ messageId: 'msg-2' }));
const result = await outbox.read('test-team');
expect(result).toHaveLength(2);
});
});

View file

@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CrossTeamService } from '@main/services/team/CrossTeamService';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamDataService } from '@main/services/team/TeamDataService';
import type { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import type { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import type { CrossTeamSendRequest, TeamConfig } from '@shared/types';
vi.mock('@main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid,
}));
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
function makeRequest(overrides: Partial<CrossTeamSendRequest> = {}): CrossTeamSendRequest {
return {
fromTeam: 'team-a',
fromMember: 'lead',
toTeam: 'team-b',
text: 'Hello from team-a',
...overrides,
};
}
function makeConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
return {
name: 'team-b',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
...overrides,
};
}
describe('CrossTeamService', () => {
let service: CrossTeamService;
let configReader: { getConfig: ReturnType<typeof vi.fn> };
let dataService: { getLeadMemberName: ReturnType<typeof vi.fn> };
let inboxWriter: { sendMessage: ReturnType<typeof vi.fn> };
let provisioning: {
isTeamAlive: ReturnType<typeof vi.fn>;
relayLeadInboxMessages: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
configReader = {
getConfig: vi.fn().mockResolvedValue(makeConfig()),
};
dataService = {
getLeadMemberName: vi.fn().mockResolvedValue('team-lead'),
};
inboxWriter = {
sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }),
};
provisioning = {
isTeamAlive: vi.fn().mockReturnValue(false),
relayLeadInboxMessages: vi.fn().mockResolvedValue(0),
};
service = new CrossTeamService(
configReader as unknown as TeamConfigReader,
dataService as unknown as TeamDataService,
inboxWriter as unknown as TeamInboxWriter,
provisioning as unknown as TeamProvisioningService
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('send', () => {
it('delivers message to inbox via inboxWriter', async () => {
const result = await service.send(makeRequest());
expect(result.deliveredToInbox).toBe(true);
expect(result.messageId).toBeDefined();
expect(inboxWriter.sendMessage).toHaveBeenCalledOnce();
const [teamName, req] = inboxWriter.sendMessage.mock.calls[0];
expect(teamName).toBe('team-b');
expect(req.member).toBe('team-lead');
expect(req.source).toBe('cross_team');
expect(req.from).toBe('team-a.lead');
expect(req.text).toContain('[Cross-team from team-a.lead | depth:0]');
});
it('calls relayLeadInboxMessages when team is alive', async () => {
provisioning.isTeamAlive.mockReturnValue(true);
await service.send(makeRequest());
expect(provisioning.relayLeadInboxMessages).toHaveBeenCalledWith('team-b');
});
it('does not relay when team is offline', async () => {
provisioning.isTeamAlive.mockReturnValue(false);
await service.send(makeRequest());
expect(provisioning.relayLeadInboxMessages).not.toHaveBeenCalled();
});
it('gracefully handles relay failure', async () => {
provisioning.isTeamAlive.mockReturnValue(true);
provisioning.relayLeadInboxMessages.mockRejectedValue(new Error('relay fail'));
const result = await service.send(makeRequest());
expect(result.deliveredToInbox).toBe(true);
});
it('rejects self-send', async () => {
await expect(service.send(makeRequest({ fromTeam: 'team-a', toTeam: 'team-a' }))).rejects.toThrow(
'same team'
);
});
it('rejects invalid team names', async () => {
await expect(service.send(makeRequest({ fromTeam: '../evil' }))).rejects.toThrow('Invalid fromTeam');
await expect(service.send(makeRequest({ toTeam: 'UPPER' }))).rejects.toThrow('Invalid toTeam');
});
it('rejects empty text', async () => {
await expect(service.send(makeRequest({ text: '' }))).rejects.toThrow('text is required');
await expect(service.send(makeRequest({ text: ' ' }))).rejects.toThrow('text is required');
});
it('rejects when target not found', async () => {
configReader.getConfig.mockResolvedValue(null);
await expect(service.send(makeRequest())).rejects.toThrow('Target team not found');
});
it('rejects when target is deleted', async () => {
configReader.getConfig.mockResolvedValue(makeConfig({ deletedAt: '2024-01-01T00:00:00Z' }));
await expect(service.send(makeRequest())).rejects.toThrow('Target team not found');
});
it('rejects excessive chain depth', async () => {
await expect(service.send(makeRequest({ chainDepth: 5 }))).rejects.toThrow('chain depth');
});
it('rejects rate limit exceeded', async () => {
for (let i = 0; i < 10; i++) {
await service.send(makeRequest({ toTeam: `team-${String.fromCharCode(98 + i)}` }));
configReader.getConfig.mockResolvedValue(
makeConfig({ name: `team-${String.fromCharCode(99 + i)}` })
);
}
configReader.getConfig.mockResolvedValue(makeConfig({ name: 'team-z' }));
await expect(service.send(makeRequest({ toTeam: 'team-z' }))).rejects.toThrow('rate limit');
});
it('uses "team-lead" as fallback when getLeadMemberName returns null', async () => {
dataService.getLeadMemberName.mockResolvedValue(null);
await service.send(makeRequest());
const [, req] = inboxWriter.sendMessage.mock.calls[0];
expect(req.member).toBe('team-lead');
});
it('uses from format "team.member"', async () => {
await service.send(makeRequest({ fromTeam: 'alpha', fromMember: 'researcher' }));
const [, req] = inboxWriter.sendMessage.mock.calls[0];
expect(req.from).toBe('alpha.researcher');
});
it('works with null provisioning', async () => {
const svc = new CrossTeamService(
configReader as unknown as TeamConfigReader,
dataService as unknown as TeamDataService,
inboxWriter as unknown as TeamInboxWriter,
null
);
const result = await svc.send(makeRequest());
expect(result.deliveredToInbox).toBe(true);
});
});
describe('listAvailableTargets', () => {
it('returns empty when teams dir read fails', async () => {
configReader.getConfig.mockRejectedValue(new Error('ENOENT'));
const result = await service.listAvailableTargets();
expect(result).toEqual([]);
});
});
describe('getOutbox', () => {
it('returns empty for non-existent outbox', async () => {
const result = await service.getOutbox('team-a');
expect(result).toEqual([]);
});
});
});

View file

@ -0,0 +1,77 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { withFileLock } from '@main/services/team/fileLock';
describe('withFileLock', () => {
let tmpDir: string;
let testFile: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filelock-test-'));
testFile = path.join(tmpDir, 'test.json');
fs.writeFileSync(testFile, '[]', 'utf8');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('acquires and releases lock around fn()', async () => {
const lockPath = `${testFile}.lock`;
const result = await withFileLock(testFile, async () => {
expect(fs.existsSync(lockPath)).toBe(true);
return 42;
});
expect(result).toBe(42);
expect(fs.existsSync(lockPath)).toBe(false);
});
it('releases lock even on error', async () => {
const lockPath = `${testFile}.lock`;
await expect(
withFileLock(testFile, async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(fs.existsSync(lockPath)).toBe(false);
});
it('serializes concurrent access', async () => {
const order: number[] = [];
const task = (id: number, delayMs: number) =>
withFileLock(testFile, async () => {
order.push(id);
await new Promise((resolve) => setTimeout(resolve, delayMs));
});
await Promise.all([task(1, 50), task(2, 10), task(3, 10)]);
expect(order).toHaveLength(3);
expect(new Set(order).size).toBe(3);
});
it('removes stale lock and acquires', async () => {
const lockPath = `${testFile}.lock`;
// Create a stale lock (timestamp 60s ago)
fs.writeFileSync(lockPath, `99999\n${Date.now() - 60_000}\n`, 'utf8');
const result = await withFileLock(testFile, async () => 'ok');
expect(result).toBe('ok');
});
it('creates parent directories for lock file', async () => {
const nested = path.join(tmpDir, 'a', 'b', 'deep.json');
const result = await withFileLock(nested, async () => 'created');
expect(result).toBe('created');
expect(fs.existsSync(`${nested}.lock`)).toBe(false);
});
});