diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ef32e3ea..9ba3cdbf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,7 +5,11 @@ on:
branches: [main]
paths:
- 'src/**'
+ - 'agent-teams-controller/**'
+ - 'mcp-server/**'
- 'test/**'
+ - '.github/workflows/**'
+ - 'pnpm-workspace.yaml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'tsconfig*.json'
@@ -17,7 +21,11 @@ on:
branches: [main]
paths:
- 'src/**'
+ - 'agent-teams-controller/**'
+ - 'mcp-server/**'
- 'test/**'
+ - '.github/workflows/**'
+ - 'pnpm-workspace.yaml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'tsconfig*.json'
@@ -54,14 +62,8 @@ jobs:
restore-keys: |
eslint-${{ runner.os }}-
- - name: Typecheck
- run: pnpm typecheck
-
- - name: Lint
- run: pnpm lint
-
- - name: Build
- run: pnpm build
+ - name: Validate workspace truth gate
+ run: pnpm check
test:
strategy:
@@ -87,4 +89,4 @@ jobs:
run: pnpm install --no-frozen-lockfile
- name: Test
- run: pnpm test
+ run: pnpm test:workspace
diff --git a/README.md b/README.md
index 37712043..bdd793b2 100644
--- a/README.md
+++ b/README.md
@@ -15,22 +15,24 @@
- 100% free, open source. No API keys. No configuration. Runs entirely locally.
+ 100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.
## What is this
-A new approach to task management with AI agents.
+A new approach to task management with AI agent teams.
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
-- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
+- **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
+- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
- **Live process section** — see which agents are running processes and open URLs directly in the browser
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
+- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
More features
@@ -38,7 +40,6 @@ A new approach to task management with AI agents.
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
-- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
diff --git a/agent-teams-controller/.gitignore b/agent-teams-controller/.gitignore
new file mode 100644
index 00000000..849ddff3
--- /dev/null
+++ b/agent-teams-controller/.gitignore
@@ -0,0 +1 @@
+dist/
diff --git a/agent-teams-controller/package.json b/agent-teams-controller/package.json
new file mode 100644
index 00000000..ce27fce1
--- /dev/null
+++ b/agent-teams-controller/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "agent-teams-controller",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Controller package for Claude agent teams operations",
+ "type": "commonjs",
+ "main": "src/index.js",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "node ./scripts/build.mjs",
+ "test": "vitest run --config vitest.config.js",
+ "test:watch": "vitest --config vitest.config.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/agent-teams-controller/scripts/build.mjs b/agent-teams-controller/scripts/build.mjs
new file mode 100644
index 00000000..bf2ce410
--- /dev/null
+++ b/agent-teams-controller/scripts/build.mjs
@@ -0,0 +1,31 @@
+import { copyFile, mkdir, readdir, rm } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const packageRoot = path.resolve(__dirname, '..');
+const srcDir = path.join(packageRoot, 'src');
+const distDir = path.join(packageRoot, 'dist');
+
+async function copyRecursive(sourceDir, targetDir) {
+ await mkdir(targetDir, { recursive: true });
+ const entries = await readdir(sourceDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const sourcePath = path.join(sourceDir, entry.name);
+ const targetPath = path.join(targetDir, entry.name);
+
+ if (entry.isDirectory()) {
+ await copyRecursive(sourcePath, targetPath);
+ continue;
+ }
+
+ if (entry.isFile()) {
+ await copyFile(sourcePath, targetPath);
+ }
+ }
+}
+
+await rm(distDir, { recursive: true, force: true });
+await mkdir(distDir, { recursive: true });
+await copyRecursive(srcDir, distDir);
diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js
new file mode 100644
index 00000000..262c33e0
--- /dev/null
+++ b/agent-teams-controller/src/controller.js
@@ -0,0 +1,44 @@
+const { createControllerContext } = require('./internal/context.js');
+const tasks = require('./internal/tasks.js');
+const kanban = require('./internal/kanban.js');
+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(
+ Object.entries(moduleApi).map(([name, fn]) => [
+ name,
+ (...args) => fn(context, ...args),
+ ])
+ );
+}
+
+function createController(options) {
+ const context = createControllerContext(options);
+
+ return {
+ context,
+ tasks: bindModule(context, tasks),
+ kanban: bindModule(context, kanban),
+ review: bindModule(context, review),
+ messages: bindModule(context, messages),
+ processes: bindModule(context, processes),
+ maintenance: bindModule(context, maintenance),
+ crossTeam: bindModule(context, crossTeam),
+ };
+}
+
+module.exports = {
+ createController,
+ createControllerContext,
+ tasks,
+ kanban,
+ review,
+ messages,
+ processes,
+ maintenance,
+ crossTeam,
+};
diff --git a/agent-teams-controller/src/index.js b/agent-teams-controller/src/index.js
new file mode 100644
index 00000000..464aade2
--- /dev/null
+++ b/agent-teams-controller/src/index.js
@@ -0,0 +1,5 @@
+const controller = require('./controller.js');
+
+module.exports = {
+ ...controller,
+};
diff --git a/agent-teams-controller/src/internal/agentBlocks.js b/agent-teams-controller/src/internal/agentBlocks.js
new file mode 100644
index 00000000..9913af91
--- /dev/null
+++ b/agent-teams-controller/src/internal/agentBlocks.js
@@ -0,0 +1,18 @@
+const AGENT_BLOCK_TAG = 'info_for_agent';
+const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
+const AGENT_BLOCK_CLOSE = `${AGENT_BLOCK_TAG}>`;
+
+function wrapAgentBlock(text) {
+ const trimmed = typeof text === 'string' ? text.trim() : '';
+ if (!trimmed) {
+ return '';
+ }
+ return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
+}
+
+module.exports = {
+ AGENT_BLOCK_TAG,
+ AGENT_BLOCK_OPEN,
+ AGENT_BLOCK_CLOSE,
+ wrapAgentBlock,
+};
diff --git a/agent-teams-controller/src/internal/capture.js b/agent-teams-controller/src/internal/capture.js
new file mode 100644
index 00000000..ecbfd597
--- /dev/null
+++ b/agent-teams-controller/src/internal/capture.js
@@ -0,0 +1,31 @@
+function captureStreamOutput(stream, fn) {
+ let output = '';
+ const originalWrite = stream.write.bind(stream);
+
+ stream.write = ((chunk, encoding, callback) => {
+ output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(encoding || 'utf8');
+ if (typeof callback === 'function') {
+ callback();
+ }
+ return true;
+ });
+
+ try {
+ const result = fn();
+ if (result && typeof result.then === 'function') {
+ return result.finally(() => {
+ stream.write = originalWrite;
+ }).then((value) => ({ value, output }));
+ }
+
+ stream.write = originalWrite;
+ return { value: result, output };
+ } catch (error) {
+ stream.write = originalWrite;
+ throw error;
+ }
+}
+
+module.exports = {
+ captureStreamOutput,
+};
diff --git a/agent-teams-controller/src/internal/cascadeGuard.js b/agent-teams-controller/src/internal/cascadeGuard.js
new file mode 100644
index 00000000..4ed0af56
--- /dev/null
+++ b/agent-teams-controller/src/internal/cascadeGuard.js
@@ -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 };
diff --git a/agent-teams-controller/src/internal/context.js b/agent-teams-controller/src/internal/context.js
new file mode 100644
index 00000000..abe218e1
--- /dev/null
+++ b/agent-teams-controller/src/internal/context.js
@@ -0,0 +1,24 @@
+const runtimeHelpers = require('./runtimeHelpers.js');
+
+function createControllerContext(options = {}) {
+ const teamName = String(options.teamName || '').trim();
+ if (!teamName) {
+ throw new Error('Missing teamName');
+ }
+
+ const flags = {};
+ if (typeof options.claudeDir === 'string' && options.claudeDir.trim()) {
+ flags['claude-dir'] = options.claudeDir.trim();
+ }
+
+ const paths = runtimeHelpers.getPaths(flags, teamName);
+ return {
+ teamName,
+ claudeDir: paths.claudeDir,
+ paths,
+ };
+}
+
+module.exports = {
+ createControllerContext,
+};
diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js
new file mode 100644
index 00000000..34216c97
--- /dev/null
+++ b/agent-teams-controller/src/internal/crossTeam.js
@@ -0,0 +1,301 @@
+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 { formatCrossTeamText, CROSS_TEAM_SOURCE } = require('./crossTeamProtocol.js');
+
+const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
+const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
+
+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 normalizeForDedupe(value) {
+ return String(value || '')
+ .trim()
+ .replace(/\s+/g, ' ')
+ .toLowerCase();
+}
+
+function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary) {
+ return [
+ normalizeForDedupe(fromTeam),
+ normalizeForDedupe(fromMember),
+ normalizeForDedupe(toTeam),
+ normalizeForDedupe(summary),
+ normalizeForDedupe(text),
+ ].join('||');
+}
+
+function getCrossTeamMessageDedupeKey(message) {
+ if (!message || typeof message !== 'object') return '';
+ return buildCrossTeamDedupeKey(
+ message.fromTeam,
+ message.fromMember,
+ message.toTeam,
+ message.text,
+ message.summary
+ );
+}
+
+function findRecentDuplicate(outboxList, dedupeKey) {
+ if (!Array.isArray(outboxList) || !dedupeKey) return null;
+ const cutoff = Date.now() - CROSS_TEAM_DEDUPE_WINDOW_MS;
+ for (let i = outboxList.length - 1; i >= 0; i -= 1) {
+ const entry = outboxList[i];
+ const ts = Date.parse(entry && entry.timestamp ? entry.timestamp : '');
+ if (!Number.isFinite(ts) || ts < cutoff) {
+ break;
+ }
+ if (getCrossTeamMessageDedupeKey(entry) === dedupeKey) {
+ return entry;
+ }
+ }
+ return null;
+}
+
+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 replyToConversationId =
+ typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
+ const conversationId =
+ typeof flags.conversationId === 'string' && flags.conversationId.trim()
+ ? flags.conversationId.trim()
+ : replyToConversationId || '';
+ 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);
+
+ // Format
+ const from = `${fromTeam}.${fromMember}`;
+ const resolvedConversationId =
+ conversationId || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
+ const formattedText = formatCrossTeamText(from, chainDepth, text, {
+ conversationId: resolvedConversationId,
+ replyToConversationId: replyToConversationId || undefined,
+ });
+ const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
+ const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
+
+ const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
+ const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
+ let duplicate = null;
+ withFileLockSync(outboxPath, () => {
+ const outbox = readJson(outboxPath, []);
+ const outList = Array.isArray(outbox) ? outbox : [];
+ duplicate = findRecentDuplicate(outList, dedupeKey);
+ if (duplicate) {
+ return;
+ }
+
+ // Cascade check only for real new deliveries.
+ cascadeGuard.check(fromTeam, toTeam, chainDepth);
+ cascadeGuard.record(fromTeam, toTeam);
+
+ // Cross-process safe inbox write
+ 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_SOURCE,
+ conversationId: resolvedConversationId,
+ replyToConversationId: replyToConversationId || undefined,
+ });
+ writeJson(inboxPath, list);
+ });
+
+ // Verify while still inside dedupe lock so duplicate callers
+ // cannot append the same request to outbox concurrently.
+ const inbox = readJson(inboxPath, []);
+ if (!inbox.some((m) => m.messageId === messageId)) {
+ throw new Error('Cross-team inbox write verification failed');
+ }
+
+ outList.push({
+ messageId,
+ fromTeam,
+ fromMember,
+ toTeam,
+ conversationId: resolvedConversationId,
+ replyToConversationId: replyToConversationId || undefined,
+ text,
+ summary,
+ chainDepth,
+ timestamp: new Date().toISOString(),
+ });
+ writeJson(outboxPath, outList);
+ });
+
+ if (duplicate) {
+ return {
+ messageId: duplicate.messageId,
+ deliveredToInbox: true,
+ deduplicated: true,
+ };
+ }
+
+ 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,
+};
diff --git a/agent-teams-controller/src/internal/crossTeamProtocol.js b/agent-teams-controller/src/internal/crossTeamProtocol.js
new file mode 100644
index 00000000..e2caa1d0
--- /dev/null
+++ b/agent-teams-controller/src/internal/crossTeamProtocol.js
@@ -0,0 +1,48 @@
+// Cross-team message protocol constants.
+// Mirror of src/shared/constants/crossTeam.ts — keep in sync.
+
+const CROSS_TEAM_TAG_NAME = 'cross-team';
+const CROSS_TEAM_ATTR_FROM = 'from';
+const CROSS_TEAM_ATTR_DEPTH = 'depth';
+const CROSS_TEAM_ATTR_CONVERSATION_ID = 'conversationId';
+const CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID = 'replyToConversationId';
+const CROSS_TEAM_SOURCE = 'cross_team';
+const CROSS_TEAM_SENT_SOURCE = 'cross_team_sent';
+
+function escapeCrossTeamAttribute(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+function formatCrossTeamPrefix(from, chainDepth, meta) {
+ const attrs = [
+ `${CROSS_TEAM_ATTR_FROM}="${escapeCrossTeamAttribute(from)}"`,
+ `${CROSS_TEAM_ATTR_DEPTH}="${String(chainDepth)}"`,
+ ];
+ if (meta && meta.conversationId) {
+ attrs.push(
+ `${CROSS_TEAM_ATTR_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.conversationId)}"`
+ );
+ }
+ if (meta && meta.replyToConversationId) {
+ attrs.push(
+ `${CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.replyToConversationId)}"`
+ );
+ }
+ return `<${CROSS_TEAM_TAG_NAME} ${attrs.join(' ')} />`;
+}
+
+function formatCrossTeamText(from, chainDepth, text, meta) {
+ return `${formatCrossTeamPrefix(from, chainDepth, meta)}\n${text}`;
+}
+
+module.exports = {
+ CROSS_TEAM_TAG_NAME,
+ CROSS_TEAM_SOURCE,
+ CROSS_TEAM_SENT_SOURCE,
+ formatCrossTeamPrefix,
+ formatCrossTeamText,
+};
diff --git a/agent-teams-controller/src/internal/fileLock.js b/agent-teams-controller/src/internal/fileLock.js
new file mode 100644
index 00000000..11ec4e7f
--- /dev/null
+++ b/agent-teams-controller/src/internal/fileLock.js
@@ -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 };
diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js
new file mode 100644
index 00000000..fcfd731b
--- /dev/null
+++ b/agent-teams-controller/src/internal/kanban.js
@@ -0,0 +1,58 @@
+const kanbanStore = require('./kanbanStore.js');
+const tasks = require('./tasks.js');
+
+function getKanbanState(context) {
+ return kanbanStore.readKanbanState(context.paths, context.teamName);
+}
+
+function setKanbanColumn(context, taskId, column) {
+ const canonicalTaskId = tasks.resolveTaskId(context, taskId);
+ kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
+ return getKanbanState(context);
+}
+
+function clearKanban(context, taskId, options) {
+ const canonicalTaskId = tasks.resolveTaskId(context, taskId);
+ kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
+ return getKanbanState(context);
+}
+
+function listReviewers(context) {
+ return getKanbanState(context).reviewers;
+}
+
+function addReviewer(context, reviewer) {
+ const state = getKanbanState(context);
+ const next = new Set(state.reviewers);
+ next.add(String(reviewer));
+ kanbanStore.writeKanbanState(context.paths, context.teamName, {
+ ...state,
+ reviewers: [...next],
+ });
+ return listReviewers(context);
+}
+
+function removeReviewer(context, reviewer) {
+ const state = getKanbanState(context);
+ const next = state.reviewers.filter((entry) => entry !== reviewer);
+ kanbanStore.writeKanbanState(context.paths, context.teamName, {
+ ...state,
+ reviewers: next,
+ });
+ return listReviewers(context);
+}
+
+function updateColumnOrder(context, columnId, orderedTaskIds) {
+ const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId));
+ return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds);
+}
+
+module.exports = {
+ getKanbanState,
+ setKanbanColumn,
+ clearKanban,
+ listReviewers,
+ addReviewer,
+ removeReviewer,
+ updateColumnOrder,
+};
diff --git a/agent-teams-controller/src/internal/kanbanStore.js b/agent-teams-controller/src/internal/kanbanStore.js
new file mode 100644
index 00000000..2ae590ae
--- /dev/null
+++ b/agent-teams-controller/src/internal/kanbanStore.js
@@ -0,0 +1,164 @@
+const fs = require('fs');
+const path = require('path');
+const taskStore = require('./taskStore.js');
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function readJson(filePath, fallbackValue) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return fallbackValue;
+ }
+}
+
+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 getDefaultState(teamName) {
+ return {
+ teamName,
+ reviewers: [],
+ tasks: {},
+ };
+}
+
+function sanitizeState(teamName, rawState) {
+ const state = rawState && typeof rawState === 'object' ? rawState : {};
+ const tasks = {};
+ if (state.tasks && typeof state.tasks === 'object') {
+ for (const [taskId, entry] of Object.entries(state.tasks)) {
+ if (!entry || typeof entry !== 'object') continue;
+ if (entry.column !== 'review' && entry.column !== 'approved') continue;
+ if (typeof entry.movedAt !== 'string') continue;
+ tasks[String(taskId)] = {
+ column: entry.column,
+ movedAt: entry.movedAt,
+ ...(entry.reviewer === null || typeof entry.reviewer === 'string'
+ ? { reviewer: entry.reviewer }
+ : {}),
+ ...(typeof entry.errorDescription === 'string'
+ ? { errorDescription: entry.errorDescription }
+ : {}),
+ };
+ }
+ }
+
+ return {
+ teamName,
+ reviewers: Array.isArray(state.reviewers)
+ ? state.reviewers.filter((entry) => typeof entry === 'string' && entry.trim())
+ : [],
+ tasks,
+ ...(state.columnOrder && typeof state.columnOrder === 'object'
+ ? { columnOrder: state.columnOrder }
+ : {}),
+ };
+}
+
+function readKanbanState(paths, teamName) {
+ return sanitizeState(teamName, readJson(paths.kanbanPath, getDefaultState(teamName)));
+}
+
+function writeKanbanState(paths, teamName, state) {
+ writeJson(paths.kanbanPath, sanitizeState(teamName, state));
+}
+
+function setKanbanColumn(paths, teamName, taskId, column) {
+ if (column !== 'review' && column !== 'approved') {
+ throw new Error(`Invalid kanban column: ${String(column)}`);
+ }
+
+ const state = readKanbanState(paths, teamName);
+ state.tasks[String(taskId)] =
+ column === 'review'
+ ? { column: 'review', reviewer: null, movedAt: nowIso() }
+ : { column: 'approved', movedAt: nowIso() };
+ writeKanbanState(paths, teamName, state);
+ taskStore.updateTask(paths, String(taskId), (task) => ({
+ ...task,
+ reviewState: column,
+ }));
+ return state;
+}
+
+function clearKanban(paths, teamName, taskId, options = {}) {
+ const state = readKanbanState(paths, teamName);
+ delete state.tasks[String(taskId)];
+ writeKanbanState(paths, teamName, state);
+ const nextReviewState =
+ typeof options.nextReviewState === 'string' ? options.nextReviewState : 'none';
+ taskStore.updateTask(paths, String(taskId), (task) => ({
+ ...task,
+ reviewState: nextReviewState,
+ }));
+ return state;
+}
+
+function updateColumnOrder(paths, teamName, columnId, orderedTaskIds) {
+ const state = readKanbanState(paths, teamName);
+ const nextColumnOrder = { ...(state.columnOrder || {}) };
+ if (Array.isArray(orderedTaskIds) && orderedTaskIds.length > 0) {
+ nextColumnOrder[columnId] = orderedTaskIds.map((entry) => String(entry));
+ } else {
+ delete nextColumnOrder[columnId];
+ }
+ state.columnOrder = Object.keys(nextColumnOrder).length > 0 ? nextColumnOrder : undefined;
+ writeKanbanState(paths, teamName, state);
+ return state;
+}
+
+function garbageCollect(paths, teamName, validTaskIds) {
+ const state = readKanbanState(paths, teamName);
+ let staleKanbanEntriesRemoved = 0;
+ let staleColumnOrderRefsRemoved = 0;
+
+ for (const taskId of Object.keys(state.tasks)) {
+ if (!validTaskIds.has(taskId)) {
+ delete state.tasks[taskId];
+ staleKanbanEntriesRemoved += 1;
+ }
+ }
+
+ if (state.columnOrder && typeof state.columnOrder === 'object') {
+ const cleaned = {};
+ for (const [columnId, orderedTaskIds] of Object.entries(state.columnOrder)) {
+ if (!Array.isArray(orderedTaskIds)) {
+ continue;
+ }
+
+ const validIds = orderedTaskIds.filter((taskId) => validTaskIds.has(String(taskId)));
+ staleColumnOrderRefsRemoved += orderedTaskIds.length - validIds.length;
+ if (validIds.length > 0) {
+ cleaned[columnId] = validIds;
+ }
+ }
+
+ state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
+ }
+
+ if (staleKanbanEntriesRemoved > 0 || staleColumnOrderRefsRemoved > 0) {
+ writeKanbanState(paths, teamName, state);
+ }
+
+ return {
+ state,
+ staleKanbanEntriesRemoved,
+ staleColumnOrderRefsRemoved,
+ };
+}
+
+module.exports = {
+ clearKanban,
+ garbageCollect,
+ readKanbanState,
+ setKanbanColumn,
+ updateColumnOrder,
+ writeKanbanState,
+};
diff --git a/agent-teams-controller/src/internal/maintenance.js b/agent-teams-controller/src/internal/maintenance.js
new file mode 100644
index 00000000..e650fc6e
--- /dev/null
+++ b/agent-teams-controller/src/internal/maintenance.js
@@ -0,0 +1,170 @@
+const fs = require('fs');
+const path = require('path');
+
+const kanbanStore = require('./kanbanStore.js');
+const taskStore = require('./taskStore.js');
+
+function listInboxNames(paths) {
+ const inboxDir = path.join(paths.teamDir, 'inboxes');
+ let entries = [];
+ try {
+ entries = fs.readdirSync(inboxDir);
+ } catch (error) {
+ if (error && error.code === 'ENOENT') {
+ return [];
+ }
+ throw error;
+ }
+
+ return entries
+ .filter((name) => name.endsWith('.json') && !name.startsWith('.'))
+ .map((name) => name.replace(/\.json$/, ''));
+}
+
+function readInboxMessages(paths) {
+ const messages = [];
+
+ for (const member of listInboxNames(paths)) {
+ const inboxPath = path.join(paths.teamDir, 'inboxes', `${member}.json`);
+ let parsed;
+ try {
+ parsed = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ } catch {
+ continue;
+ }
+
+ if (!Array.isArray(parsed)) {
+ continue;
+ }
+
+ for (const item of parsed) {
+ if (!item || typeof item !== 'object') {
+ continue;
+ }
+
+ if (
+ typeof item.from !== 'string' ||
+ typeof item.text !== 'string' ||
+ typeof item.timestamp !== 'string'
+ ) {
+ continue;
+ }
+
+ messages.push({
+ from: item.from,
+ to: typeof item.to === 'string' ? item.to : member,
+ text: item.text,
+ timestamp: item.timestamp,
+ summary: typeof item.summary === 'string' ? item.summary : undefined,
+ messageId: typeof item.messageId === 'string' ? item.messageId : undefined,
+ source: typeof item.source === 'string' ? item.source : undefined,
+ });
+ }
+ }
+
+ messages.sort((a, b) => {
+ const bt = Date.parse(b.timestamp);
+ const at = Date.parse(a.timestamp);
+ if (Number.isNaN(bt) || Number.isNaN(at)) {
+ return 0;
+ }
+ return bt - at;
+ });
+
+ return messages;
+}
+
+function isAutomatedCommentNotification(message) {
+ const summary = typeof message.summary === 'string' ? message.summary : '';
+ if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false;
+
+ const text = typeof message.text === 'string' ? message.text : '';
+ if (!text) return false;
+
+ if (text.includes('Reply to this comment using:')) return true;
+ if (text.startsWith('Comment on task #')) return true;
+ if (text.startsWith('New comment from user on your task #')) return true;
+ return false;
+}
+
+function syncLinkedComments(paths, tasks, messages) {
+ const taskIdPattern = /#([A-Za-z0-9-]+)/g;
+ const tasksById = new Map();
+ const processedTexts = new Set();
+ let linkedCommentsCreated = 0;
+
+ for (const task of tasks) {
+ tasksById.set(task.id, task);
+ if (task.displayId) {
+ tasksById.set(task.displayId, task);
+ }
+ }
+
+ for (const message of messages) {
+ if (!message.messageId || !message.summary || message.from === 'user') continue;
+ if (message.source === 'lead_session' || message.source === 'lead_process') continue;
+ if (message.source === 'system_notification') continue;
+ if (isAutomatedCommentNotification(message)) continue;
+
+ const textKey = `${message.from}\0${message.text}`;
+ if (processedTexts.has(textKey)) continue;
+ processedTexts.add(textKey);
+
+ const taskRefs = new Set();
+ for (const match of message.summary.matchAll(taskIdPattern)) {
+ taskRefs.add(match[1]);
+ }
+
+ for (const taskRef of taskRefs) {
+ const task = tasksById.get(taskRef);
+ if (!task) continue;
+
+ const commentId = `msg-${message.messageId}`;
+ const existingComments = Array.isArray(task.comments) ? task.comments : [];
+ if (existingComments.some((comment) => comment.id === commentId)) {
+ continue;
+ }
+
+ try {
+ taskStore.addTaskComment(paths, task.id, message.text, {
+ id: commentId,
+ author: message.from,
+ createdAt: message.timestamp,
+ });
+ linkedCommentsCreated += 1;
+ } catch {
+ // Best-effort: reconcile should not fail on individual comment sync writes.
+ }
+ }
+ }
+
+ return linkedCommentsCreated;
+}
+
+function reconcileArtifacts(context, options = {}) {
+ const garbageCollectKanban = options.garbageCollectKanban !== false;
+ const shouldSyncLinkedComments = options.syncLinkedComments !== false;
+ const tasks = taskStore.listTasks(context.paths);
+
+ const gcResult = garbageCollectKanban
+ ? kanbanStore.garbageCollect(
+ context.paths,
+ context.teamName,
+ new Set(tasks.map((task) => task.id))
+ )
+ : { staleKanbanEntriesRemoved: 0, staleColumnOrderRefsRemoved: 0 };
+
+ const linkedCommentsCreated = shouldSyncLinkedComments
+ ? syncLinkedComments(context.paths, tasks, readInboxMessages(context.paths))
+ : 0;
+
+ return {
+ staleKanbanEntriesRemoved: gcResult.staleKanbanEntriesRemoved,
+ staleColumnOrderRefsRemoved: gcResult.staleColumnOrderRefsRemoved,
+ linkedCommentsCreated,
+ };
+}
+
+module.exports = {
+ reconcileArtifacts,
+};
diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js
new file mode 100644
index 00000000..5bc59bfb
--- /dev/null
+++ b/agent-teams-controller/src/internal/messageStore.js
@@ -0,0 +1,150 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function ensureDir(dirPath) {
+ fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function readJson(filePath, fallbackValue) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return fallbackValue;
+ }
+}
+
+function writeJson(filePath, value) {
+ ensureDir(path.dirname(filePath));
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
+ fs.renameSync(tempPath, filePath);
+}
+
+function getInboxPath(paths, memberName) {
+ return path.join(paths.teamDir, 'inboxes', `${String(memberName).trim()}.json`);
+}
+
+function getSentMessagesPath(paths) {
+ return path.join(paths.teamDir, 'sentMessages.json');
+}
+
+function normalizeAttachments(attachments) {
+ if (!Array.isArray(attachments) || attachments.length === 0) {
+ return undefined;
+ }
+
+ const normalized = attachments
+ .filter((item) => item && typeof item === 'object')
+ .map((item) => ({
+ id: String(item.id || '').trim(),
+ filename: String(item.filename || '').trim(),
+ mimeType: String(item.mimeType || '').trim(),
+ size: Number(item.size || 0),
+ }))
+ .filter((item) => item.id && item.filename && item.mimeType && Number.isFinite(item.size));
+
+ return normalized.length > 0 ? normalized : undefined;
+}
+
+function buildMessage(flags, defaults) {
+ const timestamp =
+ typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
+ const messageId =
+ typeof flags.messageId === 'string' && flags.messageId.trim()
+ ? flags.messageId.trim()
+ : crypto.randomUUID();
+ const attachments = normalizeAttachments(flags.attachments);
+
+ return {
+ from:
+ typeof flags.from === 'string' && flags.from.trim()
+ ? flags.from.trim()
+ : defaults.from || 'user',
+ ...(defaults.to ? { to: defaults.to } : {}),
+ text: String(flags.text || ''),
+ timestamp,
+ read: defaults.read,
+ ...(typeof flags.summary === 'string' && flags.summary.trim()
+ ? { summary: flags.summary.trim() }
+ : {}),
+ ...(typeof flags.source === 'string' && flags.source.trim() ? { source: flags.source.trim() } : {}),
+ ...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
+ ? { leadSessionId: flags.leadSessionId.trim() }
+ : {}),
+ ...(typeof flags.conversationId === 'string' && flags.conversationId.trim()
+ ? { conversationId: flags.conversationId.trim() }
+ : {}),
+ ...(typeof flags.replyToConversationId === 'string' && flags.replyToConversationId.trim()
+ ? { replyToConversationId: flags.replyToConversationId.trim() }
+ : {}),
+ ...(typeof flags.color === 'string' && flags.color.trim() ? { color: flags.color.trim() } : {}),
+ ...(typeof flags.toolSummary === 'string' && flags.toolSummary.trim()
+ ? { toolSummary: flags.toolSummary.trim() }
+ : {}),
+ ...(Array.isArray(flags.toolCalls) && flags.toolCalls.length > 0
+ ? {
+ toolCalls: flags.toolCalls
+ .filter((item) => item && typeof item === 'object' && typeof item.name === 'string')
+ .map((item) => ({
+ name: item.name,
+ ...(typeof item.preview === 'string' ? { preview: item.preview } : {}),
+ })),
+ }
+ : {}),
+ ...(attachments ? { attachments } : {}),
+ messageId,
+ };
+}
+
+function appendRow(filePath, row) {
+ const current = readJson(filePath, []);
+ const list = Array.isArray(current) ? current : [];
+ list.push(row);
+ writeJson(filePath, list);
+ return row;
+}
+
+function sendInboxMessage(paths, flags) {
+ const memberName =
+ typeof flags.member === 'string' && flags.member.trim()
+ ? flags.member.trim()
+ : typeof flags.to === 'string' && flags.to.trim()
+ ? flags.to.trim()
+ : '';
+ if (!memberName) {
+ throw new Error('Missing recipient');
+ }
+
+ const payload = buildMessage(flags, {
+ from: 'user',
+ to: memberName,
+ read: false,
+ });
+ appendRow(getInboxPath(paths, memberName), payload);
+ return {
+ deliveredToInbox: true,
+ messageId: payload.messageId,
+ message: payload,
+ };
+}
+
+function appendSentMessage(paths, flags) {
+ const payload = buildMessage(flags, {
+ from: 'team-lead',
+ to: typeof flags.to === 'string' && flags.to.trim() ? flags.to.trim() : undefined,
+ read: true,
+ });
+ appendRow(getSentMessagesPath(paths), payload);
+ return payload;
+}
+
+module.exports = {
+ appendSentMessage,
+ sendInboxMessage,
+};
+
diff --git a/agent-teams-controller/src/internal/messages.js b/agent-teams-controller/src/internal/messages.js
new file mode 100644
index 00000000..c2101a77
--- /dev/null
+++ b/agent-teams-controller/src/internal/messages.js
@@ -0,0 +1,14 @@
+const messageStore = require('./messageStore.js');
+
+function sendMessage(context, flags) {
+ return messageStore.sendInboxMessage(context.paths, flags);
+}
+
+function appendSentMessage(context, flags) {
+ return messageStore.appendSentMessage(context.paths, flags);
+}
+
+module.exports = {
+ appendSentMessage,
+ sendMessage,
+};
diff --git a/agent-teams-controller/src/internal/processStore.js b/agent-teams-controller/src/internal/processStore.js
new file mode 100644
index 00000000..f2af6245
--- /dev/null
+++ b/agent-teams-controller/src/internal/processStore.js
@@ -0,0 +1,168 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const runtimeHelpers = require('./runtimeHelpers.js');
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function readJson(filePath, fallbackValue) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return fallbackValue;
+ }
+}
+
+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 readProcesses(paths) {
+ const rows = readJson(paths.processesPath, []);
+ if (!Array.isArray(rows)) return [];
+ return rows.filter((entry) => entry && typeof entry === 'object' && Number.isInteger(entry.pid));
+}
+
+function writeProcesses(paths, processes) {
+ writeJson(paths.processesPath, processes);
+}
+
+function listProcesses(paths) {
+ const existing = readProcesses(paths);
+ const processes = existing.map((entry) => {
+ const alive =
+ !entry.stoppedAt &&
+ Number.isFinite(Number(entry.pid)) &&
+ runtimeHelpers.isProcessAlive(Number(entry.pid));
+
+ if (!alive && !entry.stoppedAt) {
+ return {
+ ...entry,
+ stoppedAt: nowIso(),
+ alive: false,
+ };
+ }
+
+ return {
+ ...entry,
+ alive,
+ };
+ });
+
+ const changed = processes.some((entry, index) => entry.stoppedAt !== existing[index]?.stoppedAt);
+ if (changed) {
+ writeProcesses(
+ paths,
+ processes.map(({ alive, ...rest }) => rest)
+ );
+ }
+
+ return processes;
+}
+
+function registerProcess(paths, flags) {
+ const pid = Number(flags.pid);
+ if (!Number.isInteger(pid) || pid <= 0) {
+ throw new Error('Invalid pid');
+ }
+
+ const label = typeof flags.label === 'string' && flags.label.trim() ? flags.label.trim() : '';
+ if (!label) {
+ throw new Error('Missing label');
+ }
+
+ const list = readProcesses(paths);
+ const existingActiveIndex = list.findIndex((entry) => entry.pid === pid && !entry.stoppedAt);
+ const existingActive =
+ existingActiveIndex >= 0
+ ? {
+ ...list[existingActiveIndex],
+ ...(runtimeHelpers.isProcessAlive(pid) ? {} : { stoppedAt: nowIso() }),
+ }
+ : null;
+ if (existingActiveIndex >= 0 && existingActive && existingActive.stoppedAt) {
+ list[existingActiveIndex] = existingActive;
+ }
+ const now = nowIso();
+ const entry = {
+ id: existingActive && !existingActive.stoppedAt ? existingActive.id : crypto.randomUUID(),
+ label,
+ pid,
+ ...(flags.port != null ? { port: Number(flags.port) } : {}),
+ ...(typeof flags.url === 'string' && flags.url.trim() ? { url: flags.url.trim() } : {}),
+ ...(typeof flags['claude-process-id'] === 'string' && flags['claude-process-id'].trim()
+ ? { claudeProcessId: flags['claude-process-id'].trim() }
+ : {}),
+ ...(typeof flags.from === 'string' && flags.from.trim() ? { registeredBy: flags.from.trim() } : {}),
+ ...(typeof flags.command === 'string' && flags.command.trim()
+ ? { command: flags.command.trim() }
+ : {}),
+ registeredAt: existingActive && !existingActive.stoppedAt ? existingActive.registeredAt : now,
+ };
+
+ if (existingActiveIndex >= 0 && existingActive && !existingActive.stoppedAt) {
+ list[existingActiveIndex] = entry;
+ } else {
+ list.push(entry);
+ }
+
+ writeProcesses(paths, list);
+ return entry;
+}
+
+function stopProcess(paths, flags) {
+ const pid = flags.pid != null ? Number(flags.pid) : null;
+ const id =
+ typeof flags.id === 'string' && flags.id.trim().length > 0 ? flags.id.trim() : null;
+ if (!pid && !id) {
+ throw new Error('Missing pid or id');
+ }
+
+ const list = readProcesses(paths);
+ const index = list.findIndex((entry) => {
+ if (pid) return entry.pid === pid && !entry.stoppedAt;
+ return entry.id === id && !entry.stoppedAt;
+ });
+ if (index < 0) {
+ throw new Error('Process not found');
+ }
+
+ list[index] = {
+ ...list[index],
+ stoppedAt: list[index].stoppedAt || nowIso(),
+ };
+ writeProcesses(paths, list);
+ return list[index];
+}
+
+function unregisterProcess(paths, flags) {
+ const pid = flags.pid != null ? Number(flags.pid) : null;
+ const id =
+ typeof flags.id === 'string' && flags.id.trim().length > 0 ? flags.id.trim() : null;
+ if (!pid && !id) {
+ throw new Error('Missing pid or id');
+ }
+
+ const list = readProcesses(paths);
+ const next = list.filter((entry) => {
+ if (pid) return entry.pid !== pid;
+ return entry.id !== id;
+ });
+ writeProcesses(paths, next);
+ return next;
+}
+
+module.exports = {
+ listProcesses,
+ readProcesses,
+ registerProcess,
+ stopProcess,
+ unregisterProcess,
+ writeProcesses,
+};
diff --git a/agent-teams-controller/src/internal/processes.js b/agent-teams-controller/src/internal/processes.js
new file mode 100644
index 00000000..8cbd7331
--- /dev/null
+++ b/agent-teams-controller/src/internal/processes.js
@@ -0,0 +1,25 @@
+const processStore = require('./processStore.js');
+
+function registerProcess(context, flags) {
+ return processStore.registerProcess(context.paths, flags);
+}
+
+function unregisterProcess(context, flags) {
+ processStore.unregisterProcess(context.paths, flags);
+ return listProcesses(context);
+}
+
+function listProcesses(context) {
+ return processStore.listProcesses(context.paths);
+}
+
+function stopProcess(context, flags) {
+ return processStore.stopProcess(context.paths, flags);
+}
+
+module.exports = {
+ registerProcess,
+ stopProcess,
+ unregisterProcess,
+ listProcesses,
+};
diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js
new file mode 100644
index 00000000..a665f183
--- /dev/null
+++ b/agent-teams-controller/src/internal/review.js
@@ -0,0 +1,208 @@
+const fs = require('fs');
+const path = require('path');
+
+const kanban = require('./kanban.js');
+const messages = require('./messages.js');
+const tasks = require('./tasks.js');
+const { wrapAgentBlock } = require('./agentBlocks.js');
+
+function getReviewer(context, flags) {
+ if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
+ return flags.reviewer.trim();
+ }
+ const state = kanban.getKanbanState(context);
+ return typeof state.reviewers[0] === 'string' && state.reviewers[0].trim()
+ ? state.reviewers[0].trim()
+ : null;
+}
+
+function resolveLeadSessionId(context, flags) {
+ if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) {
+ return flags.leadSessionId.trim();
+ }
+
+ try {
+ const configPath = path.join(context.paths.teamDir, 'config.json');
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ return typeof parsed.leadSessionId === 'string' && parsed.leadSessionId.trim()
+ ? parsed.leadSessionId.trim()
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function getCurrentReviewState(task) {
+ const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
+ for (let i = events.length - 1; i >= 0; i--) {
+ const e = events[i];
+ if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') {
+ return e.to;
+ }
+ if (e.type === 'status_changed' && e.to === 'in_progress') {
+ return 'none';
+ }
+ }
+ return 'none';
+}
+
+function requestReview(context, taskId, flags = {}) {
+ const task = tasks.getTask(context, taskId);
+ if (task.status !== 'completed') {
+ throw new Error(`Task #${task.displayId || task.id} must be completed before review`);
+ }
+
+ const from =
+ typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
+ const reviewer = getReviewer(context, flags);
+ const leadSessionId = resolveLeadSessionId(context, flags);
+ const prevReviewState = getCurrentReviewState(task);
+
+ try {
+ kanban.setKanbanColumn(context, task.id, 'review');
+
+ // Append review_requested event
+ tasks.updateTask(context, task.id, (t) => {
+ t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
+ type: 'review_requested',
+ from: prevReviewState,
+ to: 'review',
+ ...(reviewer ? { reviewer } : {}),
+ actor: from,
+ });
+ t.reviewState = 'review';
+ return t;
+ });
+
+ if (!reviewer) {
+ return tasks.getTask(context, task.id);
+ }
+
+ messages.sendMessage(context, {
+ to: reviewer,
+ from,
+ text:
+ `Please review task #${task.displayId || task.id}.\n\n` +
+ wrapAgentBlock(
+ `When approved, use MCP tool review_approve:\n` +
+ `{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
+ `If changes are needed, use MCP tool review_request_changes:\n` +
+ `{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
+ ),
+ summary: `Review request for #${task.displayId || task.id}`,
+ source: 'system_notification',
+ ...(leadSessionId ? { leadSessionId } : {}),
+ });
+ return tasks.getTask(context, task.id);
+ } catch (error) {
+ try {
+ kanban.clearKanban(context, task.id);
+ } catch {
+ // Best-effort rollback: keep the original error.
+ }
+ throw error;
+ }
+}
+
+function approveReview(context, taskId, flags = {}) {
+ const task = tasks.getTask(context, taskId);
+ const from =
+ typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
+ const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
+ const leadSessionId = resolveLeadSessionId(context, flags);
+ const prevReviewState = getCurrentReviewState(task);
+
+ kanban.setKanbanColumn(context, task.id, 'approved');
+
+ // Append review_approved event
+ tasks.updateTask(context, task.id, (t) => {
+ t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
+ type: 'review_approved',
+ from: prevReviewState,
+ to: 'approved',
+ ...(note ? { note } : {}),
+ actor: from,
+ });
+ t.reviewState = 'approved';
+ return t;
+ });
+
+ tasks.addTaskComment(context, task.id, {
+ text: note,
+ from,
+ type: 'review_approved',
+ notifyOwner: false,
+ });
+
+ if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
+ messages.sendMessage(context, {
+ to: task.owner,
+ from,
+ text:
+ note && note !== 'Approved'
+ ? `Task #${task.displayId || task.id} approved.\n\n${note}`
+ : `Task #${task.displayId || task.id} approved.`,
+ summary: `Approved #${task.displayId || task.id}`,
+ source: 'system_notification',
+ ...(leadSessionId ? { leadSessionId } : {}),
+ });
+ }
+
+ return tasks.getTask(context, task.id);
+}
+
+function requestChanges(context, taskId, flags = {}) {
+ const task = tasks.getTask(context, taskId);
+ if (!task.owner) {
+ throw new Error(`No owner found for task ${String(taskId)}`);
+ }
+
+ const from =
+ typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
+ const comment =
+ typeof flags.comment === 'string' && flags.comment.trim()
+ ? flags.comment.trim()
+ : 'Reviewer requested changes.';
+ const leadSessionId = resolveLeadSessionId(context, flags);
+ const prevReviewState = getCurrentReviewState(task);
+
+ // Append review_changes_requested event before status change
+ tasks.updateTask(context, task.id, (t) => {
+ t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
+ type: 'review_changes_requested',
+ from: prevReviewState,
+ to: 'needsFix',
+ ...(comment ? { note: comment } : {}),
+ actor: from,
+ });
+ t.reviewState = 'needsFix';
+ return t;
+ });
+
+ kanban.clearKanban(context, task.id, { nextReviewState: 'needsFix' });
+ tasks.setTaskStatus(context, task.id, 'pending', from);
+ tasks.addTaskComment(context, task.id, {
+ text: comment,
+ from,
+ type: 'review_request',
+ notifyOwner: false,
+ });
+ messages.sendMessage(context, {
+ to: task.owner,
+ from,
+ text:
+ `Task #${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` +
+ 'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.',
+ summary: `Fix request for #${task.displayId || task.id}`,
+ source: 'system_notification',
+ ...(leadSessionId ? { leadSessionId } : {}),
+ });
+
+ return tasks.getTask(context, task.id);
+}
+
+module.exports = {
+ approveReview,
+ requestReview,
+ requestChanges,
+};
diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js
new file mode 100644
index 00000000..3f9e88ad
--- /dev/null
+++ b/agent-teams-controller/src/internal/runtimeHelpers.js
@@ -0,0 +1,308 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const TASK_ATTACHMENTS_DIR = 'task-attachments';
+const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function makeId() {
+ return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
+}
+
+function ensureDir(dirPath) {
+ fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function readJson(filePath, fallbackValue) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch (error) {
+ if (error && error.code === 'ENOENT') {
+ return fallbackValue;
+ }
+ throw error;
+ }
+}
+
+function isSafePathSegment(value) {
+ const normalized = String(value == null ? '' : value);
+ if (normalized.length === 0 || normalized.trim().length === 0) return false;
+ if (normalized === '.' || normalized === '..') return false;
+ if (normalized.includes('/') || normalized.includes('\\')) return false;
+ if (normalized.includes('..')) return false;
+ if (normalized.includes('\0')) return false;
+ return true;
+}
+
+function assertSafePathSegment(label, value) {
+ const normalized = String(value == null ? '' : value);
+ if (!isSafePathSegment(normalized)) {
+ throw new Error(`Invalid ${String(label)}`);
+ }
+ return normalized;
+}
+
+function getHomeDir() {
+ if (process.env.HOME) return process.env.HOME;
+ if (process.env.USERPROFILE) return process.env.USERPROFILE;
+ if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
+ return process.env.HOMEDRIVE + process.env.HOMEPATH;
+ }
+ try {
+ return require('os').homedir();
+ } catch {
+ return '';
+ }
+}
+
+function getClaudeDir(flags) {
+ const explicit =
+ (typeof flags['claude-dir'] === 'string' && flags['claude-dir']) ||
+ (typeof flags.claudeDir === 'string' && flags.claudeDir) ||
+ (typeof flags.claude_path === 'string' && flags.claude_path) ||
+ '';
+ if (explicit) {
+ return path.resolve(explicit);
+ }
+ const home = getHomeDir();
+ if (!home) {
+ throw new Error('HOME/USERPROFILE is not set');
+ }
+ return path.join(home, '.claude');
+}
+
+function getPaths(flags, teamName) {
+ const claudeDir = getClaudeDir(flags);
+ const safeTeam = assertSafePathSegment('team', teamName);
+ const teamDir = path.join(claudeDir, 'teams', safeTeam);
+ const tasksDir = path.join(claudeDir, 'tasks', safeTeam);
+ const kanbanPath = path.join(teamDir, 'kanban-state.json');
+ const processesPath = path.join(teamDir, 'processes.json');
+ return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
+}
+
+function inferLeadName(paths) {
+ const config = readTeamConfig(paths);
+ if (!config || !Array.isArray(config.members)) {
+ return 'team-lead';
+ }
+ const lead = config.members.find(
+ (member) => member && member.role && String(member.role).toLowerCase().includes('lead')
+ );
+ if (lead) {
+ return String(lead.name);
+ }
+ return config.members[0] ? String(config.members[0].name) : 'team-lead';
+}
+
+function readTeamConfig(paths) {
+ return readJson(path.join(paths.teamDir, 'config.json'), null);
+}
+
+function resolveLeadSessionId(paths) {
+ const config = readTeamConfig(paths);
+ return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
+ ? config.leadSessionId.trim()
+ : undefined;
+}
+
+function isProcessAlive(pid) {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ if (error && error.code === 'EPERM') {
+ return true;
+ }
+ return false;
+ }
+}
+
+function sanitizeFilename(original) {
+ const raw = String(original == null ? '' : original).trim();
+ const parts = raw.split(/[\\/]/);
+ const base = (parts.length ? parts[parts.length - 1] : raw).trim();
+ const cleaned = base
+ .replace(/\0/g, '')
+ .replace(/[\r\n\t]/g, ' ')
+ .replace(/[\\/]/g, '_')
+ .trim();
+ if (!cleaned) return 'attachment';
+ return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
+}
+
+function readFileHeader(filePath, maxBytes) {
+ const fd = fs.openSync(filePath, 'r');
+ try {
+ const buffer = Buffer.alloc(maxBytes);
+ const bytes = fs.readSync(fd, buffer, 0, maxBytes, 0);
+ return buffer.slice(0, bytes);
+ } finally {
+ try {
+ fs.closeSync(fd);
+ } catch {
+ // ignore
+ }
+ }
+}
+
+function startsWithBytes(buffer, bytes) {
+ if (!buffer || buffer.length < bytes.length) return false;
+ for (let i = 0; i < bytes.length; i += 1) {
+ if (buffer[i] !== bytes[i]) return false;
+ }
+ return true;
+}
+
+function detectMimeTypeFromPathAndHeader(filePath, filename) {
+ const name = String(filename || '').toLowerCase();
+ const ext = path.extname(name);
+
+ if (ext === '.png') return 'image/png';
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
+ if (ext === '.gif') return 'image/gif';
+ if (ext === '.webp') return 'image/webp';
+ if (ext === '.pdf') return 'application/pdf';
+ if (ext === '.txt') return 'text/plain';
+ if (ext === '.md') return 'text/markdown';
+ if (ext === '.json') return 'application/json';
+ if (ext === '.zip') return 'application/zip';
+
+ let header;
+ try {
+ header = readFileHeader(filePath, 16);
+ } catch {
+ return 'application/octet-stream';
+ }
+
+ if (startsWithBytes(header, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
+ if (startsWithBytes(header, [0xff, 0xd8, 0xff])) return 'image/jpeg';
+ if (header.length >= 6) {
+ const signature6 = header.slice(0, 6).toString('ascii');
+ if (signature6 === 'GIF87a' || signature6 === 'GIF89a') return 'image/gif';
+ }
+ if (header.length >= 12) {
+ const riff = header.slice(0, 4).toString('ascii');
+ const webp = header.slice(8, 12).toString('ascii');
+ if (riff === 'RIFF' && webp === 'WEBP') return 'image/webp';
+ }
+ if (header.length >= 5 && header.slice(0, 5).toString('ascii') === '%PDF-') {
+ return 'application/pdf';
+ }
+ if (startsWithBytes(header, [0x50, 0x4b, 0x03, 0x04])) return 'application/zip';
+
+ return 'application/octet-stream';
+}
+
+function getTaskAttachmentsDir(paths, taskId) {
+ const safeTaskId = assertSafePathSegment('taskId', taskId);
+ return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, safeTaskId);
+}
+
+function getStoredAttachmentPath(paths, taskId, attachmentId, filename) {
+ const safeFilename = sanitizeFilename(filename);
+ return path.join(
+ getTaskAttachmentsDir(paths, taskId),
+ `${String(attachmentId)}--${safeFilename}`
+ );
+}
+
+function ensureSourceFileReadable(srcPath) {
+ const stats = fs.statSync(srcPath);
+ if (!stats.isFile()) {
+ throw new Error(`Not a file: ${String(srcPath)}`);
+ }
+ if (stats.size > MAX_TASK_ATTACHMENT_BYTES) {
+ throw new Error(
+ `Attachment too large: ${(stats.size / (1024 * 1024)).toFixed(1)} MB (max ${String(
+ MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)
+ )} MB)`
+ );
+ }
+ return stats;
+}
+
+function copyOrLinkFile(srcPath, destPath, mode, allowFallback) {
+ const normalizedMode = String(mode || 'copy').toLowerCase();
+ if (normalizedMode === 'link') {
+ try {
+ fs.linkSync(srcPath, destPath);
+ return { mode: 'link', fallbackUsed: false };
+ } catch (error) {
+ if (!allowFallback) throw error;
+ try {
+ fs.copyFileSync(srcPath, destPath);
+ return { mode: 'copy', fallbackUsed: true };
+ } catch (copyError) {
+ throw copyError || error;
+ }
+ }
+ }
+
+ fs.copyFileSync(srcPath, destPath);
+ return { mode: 'copy', fallbackUsed: false };
+}
+
+function saveTaskAttachmentFile(paths, taskId, flags) {
+ const rawFile =
+ (typeof flags.file === 'string' && flags.file.trim()) ||
+ (typeof flags.path === 'string' && flags.path.trim()) ||
+ '';
+ if (!rawFile) {
+ throw new Error('Missing --file ');
+ }
+
+ const srcPath = path.resolve(rawFile);
+ ensureSourceFileReadable(srcPath);
+
+ const filename =
+ (typeof flags.filename === 'string' && flags.filename.trim()) || path.basename(srcPath);
+ const mimeType =
+ (typeof flags['mime-type'] === 'string' && flags['mime-type'].trim()) ||
+ (typeof flags.mimeType === 'string' && flags.mimeType.trim()) ||
+ detectMimeTypeFromPathAndHeader(srcPath, filename);
+
+ const attachmentId = makeId();
+ const dir = getTaskAttachmentsDir(paths, taskId);
+ ensureDir(dir);
+ const destPath = getStoredAttachmentPath(paths, taskId, attachmentId, filename);
+ const allowFallback = !(flags['no-fallback'] === true);
+
+ if (fs.existsSync(destPath)) {
+ throw new Error('Attachment destination already exists');
+ }
+
+ const result = copyOrLinkFile(srcPath, destPath, flags.mode, allowFallback);
+ const stats = fs.statSync(destPath);
+ if (!stats.isFile() || stats.size < 0) {
+ throw new Error('Attachment write verification failed');
+ }
+
+ const meta = {
+ id: attachmentId,
+ filename,
+ mimeType,
+ size: stats.size,
+ addedAt: nowIso(),
+ };
+
+ return {
+ meta,
+ storedPath: destPath,
+ storageMode: result.mode,
+ fallbackUsed: result.fallbackUsed,
+ };
+}
+
+module.exports = {
+ getPaths,
+ inferLeadName,
+ isProcessAlive,
+ readTeamConfig,
+ resolveLeadSessionId,
+ saveTaskAttachmentFile,
+};
diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js
new file mode 100644
index 00000000..f02216e9
--- /dev/null
+++ b/agent-teams-controller/src/internal/taskStore.js
@@ -0,0 +1,782 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
+const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
+const UUID_TASK_ID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function ensureDir(dirPath) {
+ fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function readJson(filePath, fallbackValue) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return fallbackValue;
+ }
+}
+
+function writeJson(filePath, value) {
+ ensureDir(path.dirname(filePath));
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
+ fs.renameSync(tempPath, filePath);
+}
+
+function getTaskPath(paths, taskId) {
+ return path.join(paths.tasksDir, `${String(taskId)}.json`);
+}
+
+function looksLikeCanonicalTaskId(taskId) {
+ return UUID_TASK_ID_PATTERN.test(String(taskId || '').trim());
+}
+
+function deriveDisplayId(taskId) {
+ const normalized = String(taskId || '').trim();
+ if (!normalized) return normalized;
+ return looksLikeCanonicalTaskId(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized;
+}
+
+function normalizeTask(rawTask, filePath) {
+ if (!rawTask || typeof rawTask !== 'object') {
+ throw new Error(`Invalid task payload${filePath ? `: ${filePath}` : ''}`);
+ }
+
+ const id =
+ typeof rawTask.id === 'string' || typeof rawTask.id === 'number' ? String(rawTask.id) : '';
+ if (!id) {
+ throw new Error(`Task is missing id${filePath ? `: ${filePath}` : ''}`);
+ }
+
+ const task = {
+ ...rawTask,
+ id,
+ displayId:
+ typeof rawTask.displayId === 'string' && rawTask.displayId.trim()
+ ? rawTask.displayId.trim()
+ : deriveDisplayId(id),
+ reviewState: normalizeTaskReviewState(rawTask.reviewState),
+ };
+
+ return task;
+}
+
+function normalizeTaskReviewState(value) {
+ return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
+}
+
+function listRawTasks(paths) {
+ ensureDir(paths.tasksDir);
+ const entries = fs.readdirSync(paths.tasksDir);
+ const out = [];
+
+ for (const fileName of entries) {
+ if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
+ const filePath = path.join(paths.tasksDir, fileName);
+ const rawTask = readJson(filePath, null);
+ if (!rawTask) continue;
+ if (rawTask.metadata && rawTask.metadata._internal === true) continue;
+ try {
+ out.push(normalizeTask(rawTask, filePath));
+ } catch {
+ // Skip unreadable task rows.
+ }
+ }
+
+ out.sort((a, b) => {
+ const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+ if (byDisplay !== 0) return byDisplay;
+ return String(a.id).localeCompare(String(b.id), undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+ });
+
+ return out;
+}
+
+function listTasks(paths, options = {}) {
+ const includeDeleted = options.includeDeleted === true;
+ return listRawTasks(paths).filter((task) => includeDeleted || task.status !== 'deleted');
+}
+
+function resolveTaskRef(paths, taskRef, options = {}) {
+ const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
+ if (!normalizedRef) {
+ throw new Error('Missing taskId');
+ }
+
+ const includeDeleted = options.includeDeleted === true;
+ const tasks = listRawTasks(paths);
+ const exact = tasks.find((task) => task.id === normalizedRef);
+ if (exact && (includeDeleted || exact.status !== 'deleted')) {
+ return exact.id;
+ }
+
+ const byDisplay = tasks.find(
+ (task) =>
+ task.displayId === normalizedRef &&
+ (includeDeleted || task.status !== 'deleted')
+ );
+ if (byDisplay) {
+ return byDisplay.id;
+ }
+
+ throw new Error(`Task not found: ${normalizedRef}`);
+}
+
+function readTask(paths, taskRef, options = {}) {
+ const taskId = resolveTaskRef(paths, taskRef, options);
+ const taskPath = getTaskPath(paths, taskId);
+ const rawTask = readJson(taskPath, null);
+ if (!rawTask) {
+ throw new Error(`Task not found: ${String(taskRef)}`);
+ }
+ return normalizeTask(rawTask, taskPath);
+}
+
+function appendHistoryEvent(events, event) {
+ const list = Array.isArray(events) ? [...events] : [];
+ list.push({ id: crypto.randomUUID(), timestamp: nowIso(), ...event });
+ return list;
+}
+
+function normalizeStatus(status) {
+ const normalized = String(status || '').trim();
+ return TASK_STATUSES.has(normalized) ? normalized : null;
+}
+
+function parseRelationshipList(paths, value) {
+ const rawValues = Array.isArray(value)
+ ? value
+ : typeof value === 'string'
+ ? value.split(',').map((entry) => entry.trim()).filter(Boolean)
+ : [];
+
+ return rawValues.map((entry) => resolveTaskRef(paths, entry));
+}
+
+function computeInitialStatus(paths, input, owner, blockedByIds) {
+ const explicit = normalizeStatus(input.status);
+ if (explicit) return explicit;
+ if (blockedByIds.length > 0) return 'pending';
+ if (owner && input.startImmediately === true) return 'in_progress';
+ return 'pending';
+}
+
+function pickTaskId(input) {
+ if (typeof input.id === 'string' && input.id.trim()) {
+ return input.id.trim();
+ }
+ return crypto.randomUUID();
+}
+
+function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) {
+ const preferred =
+ typeof explicitDisplayId === 'string' && explicitDisplayId.trim()
+ ? explicitDisplayId.trim()
+ : deriveDisplayId(canonicalId);
+
+ const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id)));
+ if (!existing.has(preferred)) {
+ return preferred;
+ }
+
+ let length = Math.max(preferred.length, 8);
+ while (length < canonicalId.length) {
+ const candidate = canonicalId.slice(0, length).toLowerCase();
+ if (!existing.has(candidate)) {
+ return candidate;
+ }
+ length += 1;
+ }
+
+ return canonicalId.toLowerCase();
+}
+
+function wouldCreateBlockCycle(paths, sourceId, targetId) {
+ const visited = new Set();
+ const stack = [targetId];
+
+ while (stack.length > 0) {
+ const currentId = stack.pop();
+ if (!currentId || visited.has(currentId)) continue;
+ if (currentId === sourceId) return true;
+ visited.add(currentId);
+ try {
+ const currentTask = readTask(paths, currentId, { includeDeleted: true });
+ for (const depId of currentTask.blockedBy || []) {
+ stack.push(depId);
+ }
+ } catch {
+ // Ignore unreadable dependency rows during cycle probe.
+ }
+ }
+
+ return false;
+}
+
+function writeTask(paths, task) {
+ writeJson(getTaskPath(paths, task.id), task);
+}
+
+function createTask(paths, input = {}) {
+ ensureDir(paths.tasksDir);
+
+ const canonicalId = pickTaskId(input);
+ if (fs.existsSync(getTaskPath(paths, canonicalId))) {
+ throw new Error(`Task already exists: ${canonicalId}`);
+ }
+
+ const blockedByIds = parseRelationshipList(paths, input['blocked-by'] ?? input.blockedBy);
+ const relatedIds = parseRelationshipList(paths, input.related);
+ const owner =
+ typeof input.owner === 'string' && input.owner.trim() ? input.owner.trim() : undefined;
+ const createdBy =
+ typeof input.from === 'string' && input.from.trim()
+ ? input.from.trim()
+ : typeof input.createdBy === 'string' && input.createdBy.trim()
+ ? input.createdBy.trim()
+ : undefined;
+ const createdAt =
+ typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso();
+ const status = computeInitialStatus(paths, input, owner, blockedByIds);
+ const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
+
+ for (const depId of blockedByIds) {
+ if (wouldCreateBlockCycle(paths, canonicalId, depId)) {
+ throw new Error(`Circular dependency: ${depId} already depends on ${canonicalId}`);
+ }
+ }
+
+ const task = normalizeTask({
+ id: canonicalId,
+ displayId,
+ subject:
+ typeof input.subject === 'string' && input.subject.trim()
+ ? input.subject.trim()
+ : String(input.subject || '').trim(),
+ description:
+ typeof input.description === 'string' && input.description.length > 0
+ ? input.description
+ : String(input.subject || '').trim(),
+ activeForm:
+ typeof input.activeForm === 'string'
+ ? input.activeForm
+ : typeof input['active-form'] === 'string'
+ ? input['active-form']
+ : undefined,
+ owner,
+ createdBy,
+ status,
+ createdAt,
+ updatedAt: createdAt,
+ workIntervals:
+ status === 'in_progress'
+ ? [{ startedAt: createdAt }]
+ : Array.isArray(input.workIntervals)
+ ? input.workIntervals
+ : undefined,
+ historyEvents: appendHistoryEvent(undefined, {
+ type: 'task_created',
+ status,
+ ...(createdBy ? { actor: createdBy } : {}),
+ timestamp: createdAt,
+ }),
+ blocks: Array.isArray(input.blocks) ? [...input.blocks] : [],
+ blockedBy: blockedByIds,
+ related: relatedIds.length > 0 ? relatedIds : undefined,
+ projectPath:
+ typeof input.projectPath === 'string' && input.projectPath.trim()
+ ? input.projectPath.trim()
+ : undefined,
+ comments: Array.isArray(input.comments) ? input.comments : undefined,
+ needsClarification:
+ input.needsClarification === 'lead' || input.needsClarification === 'user'
+ ? input.needsClarification
+ : undefined,
+ reviewState: normalizeTaskReviewState(input.reviewState),
+ deletedAt:
+ status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
+ attachments: Array.isArray(input.attachments) ? input.attachments : undefined,
+ });
+
+ if (!task.subject) {
+ throw new Error('Missing subject');
+ }
+
+ writeTask(paths, task);
+
+ for (const depId of blockedByIds) {
+ const dependencyTask = readTask(paths, depId, { includeDeleted: true });
+ const dependencyBlocks = Array.isArray(dependencyTask.blocks) ? dependencyTask.blocks : [];
+ if (!dependencyBlocks.includes(task.id)) {
+ dependencyTask.blocks = dependencyBlocks.concat([task.id]);
+ dependencyTask.updatedAt = nowIso();
+ writeTask(paths, dependencyTask);
+ }
+ }
+
+ for (const relatedId of relatedIds) {
+ const relatedTask = readTask(paths, relatedId, { includeDeleted: true });
+ const existingRelated = Array.isArray(relatedTask.related) ? relatedTask.related : [];
+ if (!existingRelated.includes(task.id)) {
+ relatedTask.related = existingRelated.concat([task.id]);
+ relatedTask.updatedAt = nowIso();
+ writeTask(paths, relatedTask);
+ }
+ }
+
+ return task;
+}
+
+function updateTask(paths, taskRef, updater, options = {}) {
+ const existingTask = readTask(paths, taskRef, { includeDeleted: true });
+ const nextTask = normalizeTask(updater({ ...existingTask }) || existingTask);
+ nextTask.updatedAt = nowIso();
+ writeTask(paths, nextTask);
+ return nextTask;
+}
+
+function setTaskStatus(paths, taskRef, nextStatus, actor) {
+ const status = normalizeStatus(nextStatus);
+ if (!status) {
+ throw new Error(`Invalid status: ${String(nextStatus)}`);
+ }
+
+ return updateTask(paths, taskRef, (task) => {
+ if (task.status === status) return task;
+ const timestamp = nowIso();
+ const workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
+ const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
+
+ if (task.status !== 'in_progress' && status === 'in_progress') {
+ if (!lastInterval || typeof lastInterval.completedAt === 'string') {
+ workIntervals.push({ startedAt: timestamp });
+ }
+ } else if (task.status === 'in_progress' && status !== 'in_progress') {
+ if (lastInterval && lastInterval.completedAt === undefined) {
+ lastInterval.completedAt = timestamp;
+ }
+ }
+
+ task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
+ task.historyEvents = appendHistoryEvent(task.historyEvents, {
+ type: 'status_changed',
+ from: task.status,
+ to: status,
+ ...(actor ? { actor } : {}),
+ timestamp,
+ });
+ task.status = status;
+
+ if (status === 'deleted') {
+ task.deletedAt = timestamp;
+ } else if (task.deletedAt) {
+ delete task.deletedAt;
+ }
+
+ return task;
+ });
+}
+
+function setTaskOwner(paths, taskRef, owner) {
+ return updateTask(paths, taskRef, (task) => {
+ if (owner == null || owner === 'clear' || owner === 'none') {
+ delete task.owner;
+ } else {
+ task.owner = String(owner).trim();
+ }
+ return task;
+ });
+}
+
+function updateTaskFields(paths, taskRef, fields) {
+ return updateTask(paths, taskRef, (task) => {
+ if (fields.subject !== undefined) {
+ task.subject = fields.subject;
+ }
+ if (fields.description !== undefined) {
+ task.description = fields.description;
+ }
+ return task;
+ });
+}
+
+function normalizeMemberName(value) {
+ return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
+}
+
+function addTaskComment(paths, taskRef, text, options = {}) {
+ if (typeof text !== 'string' || !text.trim()) {
+ throw new Error('Missing comment text');
+ }
+
+ const comment = {
+ id: options.id || crypto.randomUUID(),
+ author:
+ typeof options.author === 'string' && options.author.trim()
+ ? options.author.trim()
+ : 'user',
+ text,
+ createdAt:
+ typeof options.createdAt === 'string' && options.createdAt.trim()
+ ? options.createdAt.trim()
+ : nowIso(),
+ type: options.type || 'regular',
+ ...(Array.isArray(options.attachments) && options.attachments.length > 0
+ ? { attachments: options.attachments }
+ : {}),
+ };
+
+ let inserted = false;
+ let clarificationCleared = false;
+ const task = updateTask(paths, taskRef, (currentTask) => {
+ const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
+ if (comments.some((entry) => entry.id === comment.id)) {
+ return currentTask;
+ }
+
+ const authorName = normalizeMemberName(comment.author);
+ const ownerName = normalizeMemberName(currentTask.owner);
+ if (currentTask.needsClarification === 'lead' && authorName && authorName !== ownerName) {
+ delete currentTask.needsClarification;
+ clarificationCleared = true;
+ }
+ if (currentTask.needsClarification === 'user' && authorName === 'user') {
+ delete currentTask.needsClarification;
+ clarificationCleared = true;
+ }
+
+ currentTask.comments = comments.concat([comment]);
+ inserted = true;
+ return currentTask;
+ });
+
+ return { comment, task, inserted, clarificationCleared };
+}
+
+function setNeedsClarification(paths, taskRef, value) {
+ return updateTask(paths, taskRef, (task) => {
+ if (value === null || value === 'clear') {
+ delete task.needsClarification;
+ } else if (value === 'lead' || value === 'user') {
+ task.needsClarification = value;
+ } else {
+ throw new Error(`Invalid clarification value: ${String(value)}`);
+ }
+ return task;
+ });
+}
+
+function addTaskAttachmentMeta(paths, taskRef, meta) {
+ return updateTask(paths, taskRef, (task) => {
+ const attachments = Array.isArray(task.attachments) ? task.attachments : [];
+ if (!attachments.some((entry) => entry.id === meta.id)) {
+ task.attachments = attachments.concat([meta]);
+ }
+ return task;
+ });
+}
+
+function removeTaskAttachment(paths, taskRef, attachmentId) {
+ return updateTask(paths, taskRef, (task) => {
+ const attachments = Array.isArray(task.attachments) ? task.attachments : [];
+ const filtered = attachments.filter((entry) => entry.id !== attachmentId);
+ if (filtered.length > 0) task.attachments = filtered;
+ else delete task.attachments;
+ return task;
+ });
+}
+
+function addCommentAttachmentMeta(paths, taskRef, commentRef, meta) {
+ return updateTask(paths, taskRef, (task) => {
+ const comments = Array.isArray(task.comments) ? [...task.comments] : [];
+ const commentIndex = comments.findIndex((entry) => String(entry.id) === String(commentRef));
+ if (commentIndex < 0) {
+ throw new Error(`Comment not found: ${String(commentRef)}`);
+ }
+ const comment = { ...comments[commentIndex] };
+ const attachments = Array.isArray(comment.attachments) ? comment.attachments : [];
+ if (!attachments.some((entry) => entry.id === meta.id)) {
+ comment.attachments = attachments.concat([meta]);
+ }
+ comments[commentIndex] = comment;
+ task.comments = comments;
+ return task;
+ });
+}
+
+function linkTask(paths, taskRef, targetRef, relationship) {
+ const sourceId = resolveTaskRef(paths, taskRef);
+ const targetId = resolveTaskRef(paths, targetRef);
+ if (sourceId === targetId) {
+ throw new Error('Cannot link a task to itself');
+ }
+
+ if (relationship === 'blocks') {
+ return linkTask(paths, targetId, sourceId, 'blocked-by');
+ }
+
+ if (relationship === 'blocked-by') {
+ if (wouldCreateBlockCycle(paths, sourceId, targetId)) {
+ throw new Error(`Circular dependency: ${targetId} already depends on ${sourceId}`);
+ }
+
+ const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
+ const targetTask = readTask(paths, targetId, { includeDeleted: true });
+ if (!(sourceTask.blockedBy || []).includes(targetId)) {
+ sourceTask.blockedBy = [...(sourceTask.blockedBy || []), targetId];
+ writeTask(paths, sourceTask);
+ }
+ if (!(targetTask.blocks || []).includes(sourceId)) {
+ targetTask.blocks = [...(targetTask.blocks || []), sourceId];
+ writeTask(paths, targetTask);
+ }
+ return readTask(paths, sourceId, { includeDeleted: true });
+ }
+
+ if (relationship !== 'related') {
+ throw new Error(`Unsupported relationship: ${String(relationship)}`);
+ }
+
+ const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
+ const targetTask = readTask(paths, targetId, { includeDeleted: true });
+ if (!(sourceTask.related || []).includes(targetId)) {
+ sourceTask.related = [...(sourceTask.related || []), targetId];
+ writeTask(paths, sourceTask);
+ }
+ if (!(targetTask.related || []).includes(sourceId)) {
+ targetTask.related = [...(targetTask.related || []), sourceId];
+ writeTask(paths, targetTask);
+ }
+ return readTask(paths, sourceId, { includeDeleted: true });
+}
+
+function unlinkTask(paths, taskRef, targetRef, relationship) {
+ const sourceId = resolveTaskRef(paths, taskRef, { includeDeleted: true });
+ const targetId = resolveTaskRef(paths, targetRef, { includeDeleted: true });
+
+ if (relationship === 'blocks') {
+ return unlinkTask(paths, targetId, sourceId, 'blocked-by');
+ }
+
+ const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
+ if (relationship === 'blocked-by') {
+ sourceTask.blockedBy = (sourceTask.blockedBy || []).filter((entry) => entry !== targetId);
+ writeTask(paths, sourceTask);
+ try {
+ const targetTask = readTask(paths, targetId, { includeDeleted: true });
+ targetTask.blocks = (targetTask.blocks || []).filter((entry) => entry !== sourceId);
+ writeTask(paths, targetTask);
+ } catch {
+ // Ignore missing reverse link target.
+ }
+ return readTask(paths, sourceId, { includeDeleted: true });
+ }
+
+ if (relationship !== 'related') {
+ throw new Error(`Unsupported relationship: ${String(relationship)}`);
+ }
+
+ sourceTask.related = (sourceTask.related || []).filter((entry) => entry !== targetId);
+ writeTask(paths, sourceTask);
+ try {
+ const targetTask = readTask(paths, targetId, { includeDeleted: true });
+ targetTask.related = (targetTask.related || []).filter((entry) => entry !== sourceId);
+ writeTask(paths, targetTask);
+ } catch {
+ // Ignore missing reverse link target.
+ }
+ return readTask(paths, sourceId, { includeDeleted: true });
+}
+
+function buildTaskReference(task) {
+ return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`;
+}
+
+function getTaskFreshness(task) {
+ const updated = Date.parse(String(task.updatedAt || ''));
+ if (Number.isFinite(updated) && updated > 0) return updated;
+ const created = Date.parse(String(task.createdAt || ''));
+ if (Number.isFinite(created) && created > 0) return created;
+ return 0;
+}
+
+function compareTasksByFreshness(a, b) {
+ const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
+ if (freshnessDiff !== 0) return freshnessDiff;
+ const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+ if (byDisplay !== 0) return byDisplay;
+ return String(a.id).localeCompare(String(b.id), undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+}
+
+function getEffectiveReviewState(kanbanEntry, task) {
+ // Derive from historyEvents if available
+ const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
+ for (let i = events.length - 1; i >= 0; i--) {
+ const e = events[i];
+ if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') {
+ return e.to;
+ }
+ if (e.type === 'status_changed' && e.to === 'in_progress') {
+ return 'none';
+ }
+ }
+ // Fallback to persisted reviewState or kanban
+ if (normalizeTaskReviewState(task.reviewState) !== 'none') {
+ return normalizeTaskReviewState(task.reviewState);
+ }
+ return kanbanEntry && kanbanEntry.column ? String(kanbanEntry.column) : 'none';
+}
+
+function formatBriefTaskLine(task, reviewState) {
+ const reviewSuffix = reviewState !== 'none' ? `, review=${reviewState}` : '';
+ return `- #${task.displayId || deriveDisplayId(task.id)} [status=${task.status}${reviewSuffix}] ${task.subject}`;
+}
+
+function formatCommentLine(comment) {
+ const author =
+ typeof comment.author === 'string' && comment.author.trim() ? comment.author.trim() : 'unknown';
+ const text = typeof comment.text === 'string' ? comment.text.trim() : '';
+ return ` - ${author}: ${text || '(empty comment)'}`;
+}
+
+function formatTaskBriefing(paths, teamName, memberName) {
+ const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), {
+ teamName,
+ reviewers: [],
+ tasks: {},
+ });
+ const activeTasks = listTasks(paths)
+ .filter((task) => task.owner === memberName && task.status !== 'deleted')
+ .sort(compareTasksByFreshness);
+
+ if (activeTasks.length === 0) {
+ return `No assigned tasks for ${memberName}.`;
+ }
+
+ const groups = {
+ in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
+ needs_fix: activeTasks.filter((task) => {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ return task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix';
+ }),
+ pending: activeTasks.filter((task) => {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ return task.status === 'pending' && getEffectiveReviewState(kanbanEntry, task) === 'none';
+ }),
+ review: activeTasks.filter((task) => {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ return getEffectiveReviewState(kanbanEntry, task) === 'review';
+ }),
+ completed: activeTasks.filter((task) => {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ return task.status === 'completed' && getEffectiveReviewState(kanbanEntry, task) === 'none';
+ }),
+ approved: activeTasks.filter((task) => {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ return getEffectiveReviewState(kanbanEntry, task) === 'approved';
+ }),
+ };
+
+ const lines = [`Task briefing for ${memberName}:`];
+
+ if (groups.in_progress.length > 0) {
+ lines.push('', 'In progress:');
+ for (const task of groups.in_progress) {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ const reviewState = getEffectiveReviewState(kanbanEntry, task);
+ lines.push(formatBriefTaskLine(task, reviewState));
+ if (task.description) {
+ lines.push(` Description: ${task.description}`);
+ }
+ if (Array.isArray(task.comments) && task.comments.length > 0) {
+ lines.push(' Comments:');
+ for (const comment of task.comments) {
+ lines.push(formatCommentLine(comment));
+ }
+ }
+ }
+ }
+
+ if (groups.needs_fix.length > 0) {
+ lines.push('', 'Needs fixes after review:');
+ for (const task of groups.needs_fix) {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
+ }
+ }
+
+ if (groups.pending.length > 0) {
+ lines.push('', 'Pending:');
+ for (const task of groups.pending) {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
+ }
+ }
+
+ if (groups.review.length > 0) {
+ lines.push('', 'Review:');
+ for (const task of groups.review) {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
+ }
+ }
+
+ if (groups.completed.length > 0) {
+ lines.push('', 'Completed:');
+ for (const task of groups.completed) {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
+ }
+ }
+
+ if (groups.approved.length > 0) {
+ lines.push('', 'Approved (last 10):');
+ for (const task of groups.approved.slice(0, 10)) {
+ const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
+ lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
+ }
+ }
+
+ return lines.join('\n');
+}
+
+module.exports = {
+ addCommentAttachmentMeta,
+ addTaskAttachmentMeta,
+ addTaskComment,
+ appendHistoryEvent,
+ buildTaskReference,
+ createTask,
+ deriveDisplayId,
+ formatTaskBriefing,
+ linkTask,
+ listTasks,
+ readTask,
+ removeTaskAttachment,
+ resolveTaskRef,
+ setNeedsClarification,
+ setTaskOwner,
+ setTaskStatus,
+ unlinkTask,
+ updateTask,
+ updateTaskFields,
+};
diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js
new file mode 100644
index 00000000..67f61eab
--- /dev/null
+++ b/agent-teams-controller/src/internal/tasks.js
@@ -0,0 +1,320 @@
+const taskStore = require('./taskStore.js');
+const runtimeHelpers = require('./runtimeHelpers.js');
+const messages = require('./messages.js');
+const { wrapAgentBlock } = require('./agentBlocks.js');
+
+function normalizeActorName(value) {
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
+}
+
+function isSameMember(left, right) {
+ return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
+}
+
+function isSameTaskMember(left, right, leadName) {
+ const normalizedLeft = normalizeActorName(left).toLowerCase();
+ const normalizedRight = normalizeActorName(right).toLowerCase();
+ const normalizedLead = normalizeActorName(leadName).toLowerCase();
+ if (!normalizedLeft || !normalizedRight) {
+ return false;
+ }
+ if (normalizedLeft === normalizedRight) {
+ return true;
+ }
+ return (
+ (normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) ||
+ (normalizedRight === 'team-lead' && normalizedLeft === normalizedLead)
+ );
+}
+
+function buildAssignmentMessage(context, task, options = {}) {
+ const description =
+ typeof options.description === 'string' && options.description.trim()
+ ? options.description.trim()
+ : typeof task.description === 'string' && task.description.trim()
+ ? task.description.trim()
+ : '';
+ const prompt =
+ typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
+ const taskLabel = `#${task.displayId || task.id}`;
+ const lines = [`New task assigned to you: ${taskLabel} "${task.subject}".`];
+
+ if (description) {
+ lines.push(``, `Description:`, description);
+ }
+
+ if (prompt) {
+ lines.push(``, `Instructions:`, prompt);
+ }
+
+ lines.push(
+ ``,
+ wrapAgentBlock(`Use the board MCP tools to work this task correctly:
+1. Check the latest full context before starting:
+ task_get { teamName: "${context.teamName}", taskId: "${task.id}" }
+2. When you actually begin work, mark it started:
+ task_start { teamName: "${context.teamName}", taskId: "${task.id}" }
+3. When the work is done, mark it completed:
+ task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
+ );
+
+ return lines.join('\n');
+}
+
+function buildCommentNotificationMessage(context, task, comment) {
+ const taskLabel = `#${task.displayId || task.id}`;
+ return [
+ `Comment on task ${taskLabel} "${task.subject}":`,
+ ``,
+ comment.text,
+ ``,
+ wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
+{ teamName: "${context.teamName}", taskId: "${task.id}", text: "", from: "" }`),
+ ].join('\n');
+}
+
+function maybeNotifyAssignedOwner(context, task, options = {}) {
+ const owner = normalizeActorName(task.owner);
+ if (!owner || task.status === 'deleted') {
+ return;
+ }
+
+ const leadName = runtimeHelpers.inferLeadName(context.paths);
+ const sender = normalizeActorName(options.from) || leadName;
+ const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
+ if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
+ return;
+ }
+
+ const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
+ messages.sendMessage(context, {
+ member: owner,
+ from: sender,
+ text: buildAssignmentMessage(context, task, options),
+ summary,
+ source: 'system_notification',
+ ...(leadSessionId ? { leadSessionId } : {}),
+ });
+}
+
+function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
+ if (!options.inserted || options.notifyOwner === false) {
+ return;
+ }
+ if (!task || task.status === 'deleted') {
+ return;
+ }
+ if (comment.type && comment.type !== 'regular') {
+ return;
+ }
+
+ const owner = normalizeActorName(task.owner);
+ if (!owner) {
+ return;
+ }
+
+ const leadName = runtimeHelpers.inferLeadName(context.paths);
+ if (isSameTaskMember(owner, comment.author, leadName)) {
+ return;
+ }
+
+ const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
+ messages.sendMessage(context, {
+ member: owner,
+ from: normalizeActorName(comment.author) || leadName,
+ text: buildCommentNotificationMessage(context, task, comment),
+ summary: `Comment on #${task.displayId || task.id}`,
+ source: 'system_notification',
+ ...(leadSessionId ? { leadSessionId } : {}),
+ });
+}
+
+function createTask(context, input) {
+ const task = taskStore.createTask(context.paths, input);
+ if (input && input.notifyOwner !== false) {
+ maybeNotifyAssignedOwner(context, task, {
+ description: input.description,
+ prompt: input.prompt,
+ from: input.from,
+ });
+ }
+ return task;
+}
+
+function getTask(context, taskId) {
+ return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
+}
+
+function listTasks(context) {
+ return taskStore.listTasks(context.paths);
+}
+
+function listDeletedTasks(context) {
+ return taskStore.listTasks(context.paths, { includeDeleted: true }).filter(
+ (task) => task.status === 'deleted'
+ );
+}
+
+function resolveTaskId(context, taskRef) {
+ return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true });
+}
+
+function setTaskStatus(context, taskId, status, actor) {
+ return taskStore.setTaskStatus(context.paths, taskId, status, actor);
+}
+
+function startTask(context, taskId, actor) {
+ const task = setTaskStatus(context, taskId, 'in_progress', actor);
+ // Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
+ try {
+ const kanbanStore = require('./kanbanStore.js');
+ const state = kanbanStore.readKanbanState(context.paths, context.teamName);
+ if (state.tasks[task.id]) {
+ delete state.tasks[task.id];
+ kanbanStore.writeKanbanState(context.paths, context.teamName, state);
+ }
+ } catch {
+ // Best-effort: task status already updated, kanban cleanup failure is non-fatal
+ }
+ return task;
+}
+
+function completeTask(context, taskId, actor) {
+ return setTaskStatus(context, taskId, 'completed', actor);
+}
+
+function softDeleteTask(context, taskId, actor) {
+ return setTaskStatus(context, taskId, 'deleted', actor);
+}
+
+function restoreTask(context, taskId, actor) {
+ return setTaskStatus(context, taskId, 'pending', actor || 'user');
+}
+
+function setTaskOwner(context, taskId, owner) {
+ const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
+ const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
+
+ if (
+ owner != null &&
+ normalizeActorName(updatedTask.owner) &&
+ !isSameMember(previousTask.owner, updatedTask.owner)
+ ) {
+ maybeNotifyAssignedOwner(context, updatedTask, {
+ summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
+ });
+ }
+
+ return updatedTask;
+}
+
+function updateTaskFields(context, taskId, fields) {
+ return taskStore.updateTaskFields(context.paths, taskId, fields);
+}
+
+function addTaskComment(context, taskId, flags) {
+ const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
+ author:
+ typeof flags.from === 'string' && flags.from.trim()
+ ? flags.from.trim()
+ : runtimeHelpers.inferLeadName(context.paths),
+ ...(flags.id ? { id: flags.id } : {}),
+ ...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
+ ...(flags.type ? { type: flags.type } : {}),
+ ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
+ });
+
+ try {
+ maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
+ inserted: result.inserted,
+ notifyOwner: flags.notifyOwner,
+ });
+ } catch (notifyError) {
+ // Best-effort: comment is already persisted, notification failure must not fail the call
+ if (typeof console !== 'undefined' && console.warn) {
+ console.warn(
+ `[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}`
+ );
+ }
+ }
+
+ return {
+ commentId: result.comment.id,
+ taskId: result.task.id,
+ subject: result.task.subject,
+ owner: result.task.owner,
+ task: result.task,
+ comment: result.comment,
+ };
+}
+
+function attachTaskFile(context, taskId, flags) {
+ const canonicalTaskId = resolveTaskId(context, taskId);
+ const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
+ const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
+ return {
+ ...saved.meta,
+ task,
+ };
+}
+
+function attachCommentFile(context, taskId, commentId, flags) {
+ const canonicalTaskId = resolveTaskId(context, taskId);
+ const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
+ const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
+ return {
+ ...saved.meta,
+ task,
+ };
+}
+
+function addTaskAttachmentMeta(context, taskId, meta) {
+ return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
+}
+
+function removeTaskAttachment(context, taskId, attachmentId) {
+ return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
+}
+
+function setNeedsClarification(context, taskId, value) {
+ return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
+}
+
+function linkTask(context, taskId, targetId, linkType) {
+ return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
+}
+
+function unlinkTask(context, taskId, targetId, linkType) {
+ return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
+}
+
+async function taskBriefing(context, memberName) {
+ return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
+}
+
+module.exports = {
+ addTaskAttachmentMeta,
+ addTaskComment,
+ appendHistoryEvent: taskStore.appendHistoryEvent,
+ attachTaskFile,
+ attachCommentFile,
+ completeTask,
+ createTask,
+ getTask,
+ linkTask,
+ listDeletedTasks,
+ listTasks,
+ removeTaskAttachment,
+ resolveTaskId,
+ restoreTask,
+ setNeedsClarification,
+ setTaskOwner,
+ setTaskStatus,
+ softDeleteTask,
+ startTask,
+ taskBriefing,
+ unlinkTask,
+ updateTask: (context, taskRef, updater) =>
+ taskStore.updateTask(context.paths, taskRef, updater),
+ updateTaskFields,
+};
diff --git a/agent-teams-controller/test/cascadeGuard.test.js b/agent-teams-controller/test/cascadeGuard.test.js
new file mode 100644
index 00000000..7573a60e
--- /dev/null
+++ b/agent-teams-controller/test/cascadeGuard.test.js
@@ -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();
+ });
+ });
+});
diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js
new file mode 100644
index 00000000..6f03a1b5
--- /dev/null
+++ b/agent-teams-controller/test/controller.test.js
@@ -0,0 +1,563 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { createController } = require('../src/index.js');
+
+describe('agent-teams-controller API', () => {
+ function makeClaudeDir() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
+ fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
+ fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
+ fs.writeFileSync(
+ path.join(dir, 'teams', 'my-team', 'config.json'),
+ JSON.stringify(
+ {
+ name: 'my-team',
+ leadSessionId: 'lead-session-1',
+ members: [
+ { name: 'alice', role: 'team-lead' },
+ { name: 'bob', role: 'developer' },
+ ],
+ },
+ null,
+ 2
+ )
+ );
+ return dir;
+ }
+
+ it('creates tasks and exposes grouped controller modules', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ const base = controller.tasks.createTask({ subject: 'Base task' });
+ const dependency = controller.tasks.createTask({ subject: 'Dependency task' });
+ const created = controller.tasks.createTask({
+ subject: 'Blocked task',
+ owner: 'bob',
+ 'blocked-by': `${base.displayId},${dependency.displayId}`,
+ related: base.displayId,
+ });
+
+ expect(created.id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+ );
+ expect(created.displayId).toHaveLength(8);
+ expect(created.status).toBe('pending');
+ expect(created.reviewState).toBe('none');
+ expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
+ expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
+
+ controller.kanban.addReviewer('alice');
+ controller.tasks.completeTask(created.id, 'bob');
+ controller.review.requestReview(created.id, { from: 'alice' });
+ controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
+
+ const kanbanState = controller.kanban.getKanbanState();
+ expect(kanbanState.reviewers).toEqual(['alice']);
+ expect(kanbanState.tasks[created.id].column).toBe('approved');
+ expect(controller.tasks.getTask(created.id).reviewState).toBe('approved');
+
+ const sent = controller.messages.appendSentMessage({
+ from: 'team-lead',
+ to: 'user',
+ text: 'All good',
+ leadSessionId: 'session-1',
+ source: 'lead_process',
+ attachments: [{ id: 'a1', filename: 'diff.txt', mimeType: 'text/plain', size: 12 }],
+ });
+ expect(sent.leadSessionId).toBe('session-1');
+
+ const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
+ expect(ownerInbox.at(-1).summary).toContain('Approved');
+ expect(ownerInbox.at(-1).leadSessionId).toBe('lead-session-1');
+
+ const proc = controller.processes.registerProcess({
+ pid: process.pid,
+ label: 'dev-server',
+ port: '3000',
+ });
+ expect(proc.port).toBe(3000);
+ expect(controller.processes.listProcesses()).toHaveLength(1);
+ const stopped = controller.processes.stopProcess({ pid: process.pid });
+ expect(typeof stopped.stoppedAt).toBe('string');
+ });
+
+ it('creates a fresh registry entry when an old pid was recycled without stoppedAt', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
+
+ fs.writeFileSync(
+ processesPath,
+ JSON.stringify(
+ [
+ {
+ id: 'old-entry',
+ pid: 999999,
+ label: 'stale',
+ registeredAt: '2024-01-01T00:00:00.000Z',
+ },
+ ],
+ null,
+ 2
+ )
+ );
+
+ const registered = controller.processes.registerProcess({
+ pid: 999999,
+ label: 'fresh',
+ });
+
+ expect(registered.id).not.toBe('old-entry');
+ const rows = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
+ expect(rows).toHaveLength(2);
+ expect(rows[0].stoppedAt).toBeTruthy();
+ expect(rows[1].id).toBe(registered.id);
+ });
+
+ it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing by review-aware sections', async () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ const pendingTask = controller.tasks.createTask({
+ subject: 'Queued task',
+ description: 'Do this later',
+ owner: 'bob',
+ prompt: 'Check the migration plan first.',
+ });
+ const activeTask = controller.tasks.createTask({
+ subject: 'Active task',
+ description: 'Resume immediately',
+ owner: 'bob',
+ startImmediately: true,
+ });
+ const completedTask = controller.tasks.createTask({
+ subject: 'Already done',
+ description: 'Completed task description should stay out of compact rows',
+ owner: 'bob',
+ });
+ controller.tasks.completeTask(completedTask.id, 'bob');
+ controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' });
+ const needsFixTask = controller.tasks.createTask({
+ subject: 'Fix after review',
+ owner: 'bob',
+ status: 'pending',
+ reviewState: 'needsFix',
+ createdAt: '2026-01-02T00:00:00.000Z',
+ notifyOwner: false,
+ });
+ const reviewTask = controller.tasks.createTask({
+ subject: 'Waiting for review',
+ owner: 'bob',
+ status: 'completed',
+ reviewState: 'review',
+ createdAt: '2026-01-03T00:00:00.000Z',
+ notifyOwner: false,
+ });
+ const approvedTask = controller.tasks.createTask({
+ subject: 'Approved work',
+ owner: 'bob',
+ status: 'completed',
+ reviewState: 'approved',
+ createdAt: '2026-01-04T00:00:00.000Z',
+ notifyOwner: false,
+ });
+
+ const reassignedTask = controller.tasks.createTask({ subject: 'Reassigned later' });
+ controller.tasks.setTaskOwner(reassignedTask.id, 'bob');
+
+ expect(pendingTask.status).toBe('pending');
+ expect(activeTask.status).toBe('in_progress');
+
+ const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
+ expect(ownerInbox).toHaveLength(4);
+ expect(ownerInbox[0].summary).toContain(`#${pendingTask.displayId}`);
+ expect(ownerInbox[0].text).toContain('task_get');
+ expect(ownerInbox[0].text).toContain('task_start');
+ expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
+ expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
+
+ const briefing = await controller.tasks.taskBriefing('bob');
+ expect(briefing).toContain('In progress:');
+ expect(briefing).toContain(`#${activeTask.displayId}`);
+ expect(briefing).toContain('Description: Resume immediately');
+ expect(briefing).toContain('Resumed work with latest context.');
+ expect(briefing).toContain('Needs fixes after review:');
+ expect(briefing).toContain(`#${needsFixTask.displayId}`);
+ expect(briefing).toContain('Pending:');
+ expect(briefing).toContain(`#${pendingTask.displayId}`);
+ expect(briefing).not.toContain('Description: Do this later');
+ expect(briefing).toContain('Review:');
+ expect(briefing).toContain(`#${reviewTask.displayId}`);
+ expect(briefing).toContain('Completed:');
+ expect(briefing).toContain(`#${completedTask.displayId}`);
+ expect(briefing).not.toContain(
+ 'Completed task description should stay out of compact rows'
+ );
+ expect(briefing).toContain('Approved (last 10):');
+ expect(briefing).toContain(`#${approvedTask.displayId}`);
+ });
+
+ it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({
+ subject: 'Ship migration',
+ owner: 'bob',
+ });
+
+ const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
+ fs.writeFileSync(
+ kanbanPath,
+ JSON.stringify(
+ {
+ teamName: 'my-team',
+ reviewers: [],
+ tasks: {
+ [task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
+ staleTask: { column: 'approved', movedAt: '2026-01-01T00:00:00.000Z' },
+ },
+ columnOrder: {
+ review: [task.id, 'staleTask'],
+ approved: ['staleTask'],
+ },
+ },
+ null,
+ 2
+ )
+ );
+
+ const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
+ fs.mkdirSync(inboxDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(inboxDir, 'bob.json'),
+ JSON.stringify(
+ [
+ {
+ from: 'alice',
+ to: 'bob',
+ summary: `Please revisit #${task.displayId}`,
+ messageId: 'm-1',
+ timestamp: '2026-02-23T10:00:00.000Z',
+ read: false,
+ text: 'Need one more verification pass.',
+ },
+ {
+ from: 'team-lead',
+ to: 'bob',
+ summary: `Comment on #${task.displayId}`,
+ messageId: 'm-2',
+ timestamp: '2026-02-23T11:00:00.000Z',
+ read: false,
+ text:
+ `Comment on task #${task.displayId} "Ship migration":\n\nHeads up\n\n` +
+ '\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n ',
+ },
+ ],
+ null,
+ 2
+ )
+ );
+
+ const first = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
+ expect(first.staleKanbanEntriesRemoved).toBe(1);
+ expect(first.staleColumnOrderRefsRemoved).toBe(2);
+ expect(first.linkedCommentsCreated).toBe(1);
+
+ const reloaded = controller.tasks.getTask(task.id);
+ expect(reloaded.comments).toHaveLength(1);
+ expect(reloaded.comments[0].id).toBe('msg-m-1');
+ expect(reloaded.comments[0].text).toBe('Need one more verification pass.');
+
+ const cleanedKanban = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
+ expect(cleanedKanban.tasks.staleTask).toBeUndefined();
+ expect(cleanedKanban.columnOrder.review).toEqual([task.id]);
+ expect(cleanedKanban.columnOrder.approved).toBeUndefined();
+
+ const second = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
+ expect(second.staleKanbanEntriesRemoved).toBe(0);
+ expect(second.staleColumnOrderRefsRemoved).toBe(0);
+ expect(second.linkedCommentsCreated).toBe(0);
+ });
+
+ it('tracks lifecycle history and intervals without duplicate same-status transitions', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Lifecycle task' });
+
+ expect(task.status).toBe('pending');
+ expect(task.historyEvents).toHaveLength(1);
+ expect(task.workIntervals).toBeUndefined();
+
+ const started = controller.tasks.startTask(task.id, 'bob');
+ const startedAgain = controller.tasks.startTask(task.id, 'bob');
+ const completed = controller.tasks.completeTask(task.id, 'bob');
+ const completedAgain = controller.tasks.completeTask(task.id, 'bob');
+ const deleted = controller.tasks.softDeleteTask(task.id, 'bob');
+ const restored = controller.tasks.restoreTask(task.id, 'bob');
+
+ expect(started.status).toBe('in_progress');
+ expect(startedAgain.historyEvents).toHaveLength(2);
+ expect(startedAgain.workIntervals).toHaveLength(1);
+ expect(startedAgain.workIntervals[0].startedAt).toBeTruthy();
+
+ expect(completed.status).toBe('completed');
+ expect(completedAgain.historyEvents).toHaveLength(3);
+ expect(completedAgain.workIntervals).toHaveLength(1);
+ expect(completedAgain.workIntervals[0].completedAt).toBeTruthy();
+
+ expect(deleted.status).toBe('deleted');
+ expect(deleted.deletedAt).toBeTruthy();
+ expect(restored.status).toBe('pending');
+ expect(restored.deletedAt).toBeUndefined();
+ expect(restored.historyEvents).toHaveLength(5);
+
+ // Verify the event sequence: task_created, then 4 status_changed events
+ const types = restored.historyEvents.map((e) => e.type);
+ expect(types).toEqual([
+ 'task_created',
+ 'status_changed',
+ 'status_changed',
+ 'status_changed',
+ 'status_changed',
+ ]);
+
+ // Verify the status flow: pending -> in_progress -> completed -> deleted -> pending
+ const firstEvent = restored.historyEvents[0];
+ expect(firstEvent.status).toBe('pending');
+ const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
+ expect(statusChanges).toEqual([
+ 'in_progress',
+ 'completed',
+ 'deleted',
+ 'pending',
+ ]);
+ });
+
+ it('wraps review instructions in the canonical agent block format used by the UI', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
+
+ controller.kanban.addReviewer('alice');
+ controller.tasks.completeTask(task.id, 'bob');
+ controller.review.requestReview(task.id, { from: 'team-lead' });
+
+ const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
+ const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
+
+ expect(inbox).toHaveLength(1);
+ expect(inbox[0].text).toContain('');
+ expect(inbox[0].text).toContain('review_approve');
+ expect(inbox[0].text).not.toContain('');
+ expect(inbox[0].leadSessionId).toBe('lead-session-1');
+ });
+
+ it('persists full inbox metadata through controller messages.sendMessage', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ const sent = controller.messages.sendMessage({
+ to: 'bob',
+ from: 'team-lead',
+ text: 'Need your review',
+ summary: 'Review request',
+ source: 'system_notification',
+ leadSessionId: 'session-42',
+ attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
+ });
+
+ expect(sent.deliveredToInbox).toBe(true);
+ expect(sent.messageId).toBeTruthy();
+
+ const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(rows).toHaveLength(1);
+ expect(rows[0].source).toBe('system_notification');
+ expect(rows[0].leadSessionId).toBe('session-42');
+ expect(rows[0].attachments[0].filename).toBe('note.txt');
+ });
+
+ it('wakes task owner on regular comment from another member', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false });
+
+ const commented = controller.tasks.addTaskComment(task.id, {
+ from: 'alice',
+ text: 'I found the root cause.',
+ });
+
+ expect(commented.task.comments.at(-1).text).toBe('I found the root cause.');
+ const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(rows).toHaveLength(1);
+ expect(rows[0].summary).toContain(`#${task.displayId}`);
+ expect(rows[0].text).toContain('I found the root cause.');
+ expect(rows[0].leadSessionId).toBe('lead-session-1');
+ });
+
+ it('does not wake owner for self-comments and clears user clarification when user replies', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({
+ subject: 'Need product input',
+ owner: 'bob',
+ needsClarification: 'user',
+ notifyOwner: false,
+ });
+
+ controller.tasks.addTaskComment(task.id, {
+ from: 'bob',
+ text: 'Starting to investigate.',
+ });
+
+ const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ expect(fs.existsSync(ownerInboxPath)).toBe(false);
+
+ const replied = controller.tasks.addTaskComment(task.id, {
+ from: 'user',
+ text: 'Please use the safer option.',
+ });
+
+ expect(replied.task.needsClarification).toBeUndefined();
+ const reloaded = controller.tasks.getTask(task.id);
+ expect(reloaded.needsClarification).toBeUndefined();
+ const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
+ expect(rows).toHaveLength(1);
+ expect(rows[0].text).toContain('Please use the safer option.');
+ });
+
+ it('wakes lead owner on comment from another member', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({
+ subject: 'Lead-owned task',
+ owner: 'team-lead',
+ notifyOwner: false,
+ });
+
+ controller.tasks.addTaskComment(task.id, {
+ from: 'alice',
+ text: 'Need your decision here.',
+ });
+
+ const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
+ const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(rows).toHaveLength(1);
+ expect(rows[0].from).toBe('alice');
+ expect(rows[0].text).toContain('Need your decision here.');
+ });
+
+ it('moves review back to pending+needsFix and notifies owner on requestChanges', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
+
+ controller.tasks.completeTask(task.id, 'bob');
+ controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
+ const updated = controller.review.requestChanges(task.id, {
+ from: 'alice',
+ comment: 'Please address review feedback.',
+ });
+
+ expect(updated.status).toBe('pending');
+ expect(updated.reviewState).toBe('needsFix');
+ expect(updated.comments.at(-1).type).toBe('review_request');
+
+ const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(rows.at(-1).source).toBe('system_notification');
+ expect(rows.at(-1).summary).toContain('Fix request');
+ expect(rows.at(-1).text).toContain('moved back to pending');
+ expect(rows.at(-1).text).toContain('request review again');
+ expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
+ });
+
+ it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ const approvedTasks = Array.from({ length: 12 }, (_, index) =>
+ controller.tasks.createTask({
+ subject: `Approved ${index + 1}`,
+ owner: 'bob',
+ status: 'completed',
+ reviewState: 'approved',
+ createdAt: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
+ })
+ );
+
+ const briefing = await controller.tasks.taskBriefing('bob');
+ expect(briefing).toContain('Approved (last 10):');
+ expect(briefing).toContain(`#${approvedTasks[11].displayId}`);
+ expect(briefing).toContain(`#${approvedTasks[2].displayId}`);
+ expect(briefing).not.toContain(`#${approvedTasks[1].displayId}`);
+ expect(briefing).not.toContain(`#${approvedTasks[0].displayId}`);
+ });
+
+ it('marks stale processes stopped during listing and supports unregister', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
+
+ fs.writeFileSync(
+ processesPath,
+ JSON.stringify(
+ [
+ {
+ id: 'stale-entry',
+ pid: 999999,
+ label: 'stale',
+ registeredAt: '2024-01-01T00:00:00.000Z',
+ },
+ ],
+ null,
+ 2
+ )
+ );
+
+ const listed = controller.processes.listProcesses();
+ expect(listed).toHaveLength(1);
+ expect(listed[0].alive).toBe(false);
+ expect(listed[0].stoppedAt).toBeTruthy();
+
+ const persisted = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
+ expect(persisted[0].stoppedAt).toBeTruthy();
+
+ controller.processes.unregisterProcess({ id: 'stale-entry' });
+ expect(controller.processes.listProcesses()).toEqual([]);
+ });
+
+ it('task_add_comment succeeds even when owner notification write fails', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({
+ subject: 'Comment resilience',
+ owner: 'bob',
+ notifyOwner: false,
+ });
+
+ // Make inboxes directory read-only to force notification write failure
+ const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
+ fs.mkdirSync(inboxDir, { recursive: true });
+ // Write a broken file that will cause JSON parse failure on append
+ fs.writeFileSync(path.join(inboxDir, 'bob.json'), 'NOT VALID JSON');
+
+ // Comment should still succeed despite notification failure
+ const commented = controller.tasks.addTaskComment(task.id, {
+ from: 'alice',
+ text: 'This should persist despite notification failure.',
+ });
+
+ expect(commented.commentId).toBeTruthy();
+ expect(commented.task.comments).toHaveLength(1);
+ expect(commented.task.comments[0].text).toBe(
+ 'This should persist despite notification failure.'
+ );
+ });
+});
diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js
new file mode 100644
index 00000000..729bfd55
--- /dev/null
+++ b/agent-teams-controller/test/crossTeam.test.js
@@ -0,0 +1,391 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { createController } = require('../src/index.js');
+const { CROSS_TEAM_SOURCE, CROSS_TEAM_PREFIX_TAG } = require('../src/internal/crossTeamProtocol.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_SOURCE);
+ expect(inbox[0].from).toBe('team-a.lead');
+ expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0`);
+ expect(inbox[0].conversationId).toBeTruthy();
+ expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`);
+ });
+
+ 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');
+ expect(outbox[0].conversationId).toBeTruthy();
+ });
+
+ it('preserves reply conversation metadata for explicit replies', () => {
+ 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: 'Answering the open question',
+ replyToConversationId: 'conv-123',
+ });
+
+ const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
+ const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(inbox[0].conversationId).toBe('conv-123');
+ expect(inbox[0].replyToConversationId).toBe('conv-123');
+ expect(inbox[0].text).toContain('conversation:conv-123');
+ expect(inbox[0].text).toContain('replyTo:conv-123');
+ });
+
+ it('deduplicates the same recent cross-team request', () => {
+ 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 first = controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ fromMember: 'lead',
+ text: 'Please review the API contract',
+ summary: 'Review request',
+ });
+ const second = controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ fromMember: 'lead',
+ text: 'Please review the API contract',
+ summary: ' Review request ',
+ });
+
+ expect(second.deliveredToInbox).toBe(true);
+ expect(second.deduplicated).toBe(true);
+ expect(second.messageId).toBe(first.messageId);
+
+ const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
+ const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(inbox).toHaveLength(1);
+
+ const outbox = controller.crossTeam.getCrossTeamOutbox();
+ expect(outbox).toHaveLength(1);
+ });
+
+ it('allows resending after dedupe window expires', () => {
+ 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 originalNow = Date.now;
+ let now = originalNow();
+ Date.now = () => now;
+ try {
+ const first = controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ text: 'Need a decision on the schema',
+ summary: 'Schema decision',
+ });
+
+ now += 6 * 60 * 1000;
+
+ const second = controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ text: 'Need a decision on the schema',
+ summary: 'Schema decision',
+ });
+
+ expect(second.deduplicated).toBeUndefined();
+ expect(second.messageId).not.toBe(first.messageId);
+
+ const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
+ const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(inbox).toHaveLength(2);
+
+ const updatedOutbox = controller.crossTeam.getCrossTeamOutbox();
+ expect(updatedOutbox).toHaveLength(2);
+ } finally {
+ Date.now = originalNow;
+ }
+ });
+
+ 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([]);
+ });
+ });
+});
diff --git a/agent-teams-controller/vitest.config.js b/agent-teams-controller/vitest.config.js
new file mode 100644
index 00000000..67c2a3ee
--- /dev/null
+++ b/agent-teams-controller/vitest.config.js
@@ -0,0 +1,10 @@
+const { defineConfig } = require('vitest/config');
+
+module.exports = defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['test/**/*.test.js'],
+ testTimeout: 15_000,
+ },
+});
diff --git a/docs/extensions/adr-001-contract-spike.md b/docs/extensions/adr-001-contract-spike.md
new file mode 100644
index 00000000..97061688
--- /dev/null
+++ b/docs/extensions/adr-001-contract-spike.md
@@ -0,0 +1,307 @@
+# ADR-001: Extension Store Contract Spike
+
+**Date**: 2026-03-07
+**Status**: Accepted
+
+## Context
+
+Extension Store нуждается в точных внешних контрактах перед написанием парсеров. Этот ADR фиксирует результаты contract spike.
+
+---
+
+## 1. Plugin CLI Contracts
+
+### Verified CLI Flags
+
+| Command | Syntax | Default scope |
+|---------|--------|---------------|
+| install | `claude plugin install [-s scope] ` | `user` |
+| uninstall | `claude plugin uninstall [-s scope] ` | `user` |
+| list | `claude plugin list [--json] [--available]` | — |
+| enable | `claude plugin enable [-s scope] ` | — |
+| disable | `claude plugin disable [plugin]` | — |
+
+**Scope flag**: `-s, --scope ` — values: `user`, `project`, `local`
+
+**qualifiedName format**: `@` (e.g. `context7@claude-plugins-official`)
+
+### Installed State — Source of Truth
+
+**File**: `~/.claude/plugins/installed_plugins.json`
+
+```json
+{
+ "version": 2,
+ "plugins": {
+ "": [
+ {
+ "scope": "user",
+ "installPath": "/Users/.../.claude/plugins/cache///",
+ "version": "1.0.0",
+ "installedAt": "2026-03-01T11:14:21.926Z",
+ "lastUpdated": "2026-03-01T11:14:21.926Z",
+ "gitCommitSha": "..."
+ }
+ ]
+ }
+}
+```
+
+- Key = `qualifiedName` = `@`
+- Value = array (one entry per scope installation)
+- `scope`: `"user"` | `"project"` | `"local"`
+- **pluginId** for V1 = `qualifiedName` (globally unique)
+
+### Install Counts
+
+**File**: `~/.claude/plugins/install-counts-cache.json`
+
+```json
+{
+ "": // NOT qualifiedName, just name
+}
+```
+
+- Key = plugin `name` (without marketplace suffix)
+- 157 entries in current cache
+
+### Marketplaces
+
+**File**: `~/.claude/plugins/known_marketplaces.json`
+
+```json
+{
+ "": {
+ "source": { "source": "github", "repo": "/" },
+ "installLocation": "...",
+ "lastUpdated": "..."
+ }
+}
+```
+
+- V1: we read only `claude-plugins-official` marketplace
+- Marketplace manifest: `raw.githubusercontent.com///main/.claude-plugin/marketplace.json`
+- Supports ETag/If-None-Match → 304 Not Modified
+
+### `claude plugin list --json`
+
+- Supports `--json` flag
+- Supports `--available` flag (requires `--json`)
+- Output format: TBD (не тестировали мутирующе, но флаг существует)
+
+---
+
+## 2. MCP CLI Contracts
+
+### Verified CLI Flags
+
+| Command | Syntax |
+|---------|--------|
+| add (stdio) | `claude mcp add [-s scope] [-e KEY=val...] -- [args...]` |
+| add (http) | `claude mcp add [-s scope] -t http [-H "Header: val"...] ` |
+| add (sse) | `claude mcp add [-s scope] -t sse [-H "Header: val"...] ` |
+| remove | `claude mcp remove [-s scope] ` |
+| list | `claude mcp list` |
+| get | `claude mcp get ` |
+
+**Scope flag**: `-s, --scope ` — values: `local` (default), `user`, `project`
+
+**Transport flag**: `-t, --transport ` — values: `stdio` (default), `sse`, `http`
+
+**Env flag**: `-e, --env ` — format: `KEY=value`
+
+**Header flag**: `-H, --header ` — format: `"Key: value"` — YES, SUPPORTED!
+
+**OAuth**: `--client-id`, `--client-secret`, `--callback-port` — not needed for V1
+
+### Installed State — Source of Truth
+
+**User scope**: `~/.claude.json` → `mcpServers` key
+**Project scope**: `.mcp.json` in project root
+**Local scope**: определяется в Phase 0 (likely `~/.claude.json`)
+
+---
+
+## 3. Official MCP Registry API
+
+**Base URL**: `https://registry.modelcontextprotocol.io/v0.1/servers`
+
+**Pagination**: cursor-based via `metadata.nextCursor`
+
+**Query params**:
+- `limit=N` — items per page
+- `search=` — text search
+- `cursor=` — pagination
+
+**Response structure**:
+```json
+{
+ "servers": [
+ {
+ "server": {
+ "name": "io.github.upstash/context7", // reverse-DNS ID
+ "description": "...",
+ "title": "Context7", // display name (optional)
+ "version": "1.0.30",
+ "repository": { "url": "...", "source": "github" },
+ "packages": [{ // npm install info (optional)
+ "registryType": "npm",
+ "identifier": "@upstash/context7-mcp",
+ "version": "1.0.30",
+ "transport": { "type": "stdio" },
+ "environmentVariables": [{
+ "name": "CONTEXT7_API_KEY",
+ "description": "...",
+ "isSecret": true,
+ "format": "string"
+ }]
+ }],
+ "remotes": [{ // HTTP/SSE install info (optional)
+ "type": "streamable-http",
+ "url": "https://...",
+ "headers": [{ // auth headers (optional)
+ "name": "Authorization",
+ "description": "...",
+ "isRequired": true,
+ "isSecret": true,
+ "value": "Bearer {key}" // template (optional)
+ }]
+ }]
+ },
+ "_meta": {
+ "io.modelcontextprotocol.registry/official": {
+ "status": "active",
+ "isLatest": true
+ }
+ }
+ }
+ ],
+ "metadata": {
+ "nextCursor": "...",
+ "count": N
+ }
+}
+```
+
+**Key fields for install**:
+- `packages[0].identifier` → npm package name
+- `packages[0].version` → npm version
+- `packages[0].transport.type` → `"stdio"`
+- `packages[0].environmentVariables` → env var definitions
+- `remotes[0].type` → `"streamable-http"` | `"sse"`
+- `remotes[0].url` → HTTP endpoint
+- `remotes[0].headers` → auth headers (with `isSecret`, `isRequired`)
+
+**Version handling**: Multiple versions of same server returned separately. Use `_meta.isLatest: true` to pick latest.
+
+**Auth**: No authentication required.
+
+---
+
+## 4. Glama API
+
+**Base URL**: `https://glama.ai/api/mcp/v1/servers`
+
+**Pagination**: cursor-based via `pageInfo.endCursor`
+
+**Query params**:
+- `first=N` — items per page
+- `search=` — text search
+- `after=` — pagination cursor
+
+**Response structure**:
+```json
+{
+ "pageInfo": {
+ "endCursor": "...",
+ "hasNextPage": true,
+ "hasPreviousPage": false,
+ "startCursor": "..."
+ },
+ "servers": [{
+ "id": "iu27vfrji2",
+ "name": "clelp-mcp-server",
+ "namespace": "oscarsterling",
+ "description": "...",
+ "slug": "clelp-mcp-server",
+ "url": "https://glama.ai/mcp/servers/iu27vfrji2",
+ "repository": { "url": "https://github.com/..." },
+ "spdxLicense": { "name": "MIT License", "url": "..." },
+ "tools": [],
+ "attributes": [],
+ "environmentVariablesJsonSchema": null
+ }]
+}
+```
+
+**Key differences from Official Registry**:
+- NO install info (no `packages`, no `remotes`) → can't auto-install Glama-only servers
+- Has `spdxLicense` → enrichment data
+- Has `tools[]` → enrichment data
+- Has `url` → link to Glama page
+- Pagination: `after` param (not `cursor`)
+
+**Auth**: No authentication required.
+
+---
+
+## 5. Marketplace JSON Schema
+
+**URL**: `https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json`
+
+```json
+{
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
+ "name": "claude-plugins-official",
+ "description": "...",
+ "owner": { "name": "Anthropic", "email": "..." },
+ "plugins": [
+ {
+ "name": "typescript-lsp",
+ "description": "...",
+ "version": "1.0.0",
+ "author": { "name": "Anthropic", "email": "..." },
+ "source": "./plugins/typescript-lsp", // local path OR object with URL
+ "category": "development",
+ "strict": false,
+ "lspServers": { ... } // optional
+ }
+ ]
+}
+```
+
+**Plugin fields**:
+- `name`: display/install name
+- `source`: string (local path) or `{ source: "url", url: "..." }` (external)
+- `category`: open-ended string
+- `lspServers`: optional dict → hasLspServers capability
+- No `mcpServers`, `agents`, `commands`, `hooks` found in current V1 marketplace
+- `homepage`: optional string (external plugins)
+- `tags`: NOT present in marketplace.json (will be empty in V1)
+
+**Categories found**: database, deployment, design, development, learning, monitoring, productivity, security, testing
+
+**ETag support**: Yes — `If-None-Match` → 304 Not Modified
+
+---
+
+## 6. Decisions
+
+| Decision | Value |
+|----------|-------|
+| Scope | Electron-only V1 |
+| Plugin identity key (`pluginId`) | `@` = qualifiedName |
+| Plugin install target | `qualifiedName` resolved by main from catalog |
+| Plugin scope flag | `-s, --scope` (short) / `--scope` (long) |
+| MCP scope flag | `-s, --scope` |
+| MCP transport flag | `-t, --transport` |
+| MCP env flag | `-e, --env KEY=val` |
+| MCP header flag | `-H, --header "Key: val"` — **SUPPORTED** |
+| MCP default scope | `local` |
+| Plugin default scope | `user` |
+| Official Registry API version | `v0.1` |
+| Official Registry pagination | cursor-based, `cursor` param |
+| Glama pagination | cursor-based, `after` param |
+| Latest version filter | `_meta.isLatest: true` |
+| Capability flags V1 | `hasLspServers` only (others not in current marketplace) |
+| Install counts key | plugin `name` (without `@marketplace`) |
diff --git a/docs/iterations/diff-view/phase-1-read-only-diff.md b/docs/iterations/diff-view/phase-1-read-only-diff.md
index c52e0d0a..d8e378ff 100644
--- a/docs/iterations/diff-view/phase-1-read-only-diff.md
+++ b/docs/iterations/diff-view/phase-1-read-only-diff.md
@@ -135,7 +135,7 @@ export class ChangeExtractorService {
2. Парсить файлы, ища маркеры `TaskUpdate` tool_use:
- `input.taskId === taskId && input.status === 'in_progress'` → начало
- `input.taskId === taskId && input.status === 'completed'` → конец
-3. Альтернативно: Bash teamctl `task start|complete ` (regex)
+3. Альтернативно: исторические Bash teamctl логи `task start|complete ` (regex)
4. Все tool_use Edit/Write между start и end маркерами = изменения задачи
5. Если 86% кейс (1 задача в сессии): вся сессия = задача
diff --git a/docs/iterations/diff-view/phase-3-per-task-scoping.md b/docs/iterations/diff-view/phase-3-per-task-scoping.md
index 778f2846..9ae79a7d 100644
--- a/docs/iterations/diff-view/phase-3-per-task-scoping.md
+++ b/docs/iterations/diff-view/phase-3-per-task-scoping.md
@@ -22,7 +22,7 @@ export interface TaskBoundary {
/** ISO timestamp из JSONL entry */
timestamp: string;
/** Каким механизмом обнаружено */
- mechanism: 'TaskUpdate' | 'teamctl';
+ mechanism: 'TaskUpdate' | 'teamctl'; // historical legacy mechanism
/** tool_use id (для link к конкретному блоку) */
toolUseId?: string;
}
@@ -61,7 +61,7 @@ export interface TaskBoundariesResult {
/** True если сессия работала только с одной задачей */
isSingleTaskSession: boolean;
/** Механизм обнаружения (один на сессию — никогда не смешиваются!) */
- detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none';
+ detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none'; // historical legacy mechanism in this design note
}
/** Расширенный TaskChangeSet с confidence деталями.
@@ -77,7 +77,7 @@ export interface TaskChangeSetV2 extends TaskChangeSet {
### 2. Сервис: `src/main/services/team/TaskBoundaryParser.ts` (NEW)
-**Задача**: Парсить JSONL файлы субагентов для извлечения `TaskUpdate` и `teamctl` маркеров задач.
+**Задача**: Парсить JSONL файлы субагентов для извлечения `TaskUpdate` и исторических `teamctl` маркеров задач.
**Ключевой факт**: Механизмы НИКОГДА не смешиваются в одной сессии (0 из 351 проверенных). Это означает один pass по JSONL для определения механизма + extraction.
@@ -90,7 +90,7 @@ export class TaskBoundaryParser {
private readonly CACHE_TTL = 60 * 1000; // 1 мин (не 3 — JSONL файлы меняются часто при активной работе)
/**
- * Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры.
+ * Парсит JSONL файл и извлекает все TaskUpdate/исторические teamctl маркеры.
*
* Один проход по файлу, O(n) по количеству строк.
*/
@@ -101,8 +101,8 @@ export class TaskBoundaryParser {
*
* Алгоритм:
* 1. Найти все TaskBoundary для taskId
- * 2. Start boundary = TaskUpdate(in_progress) или teamctl(start)
- * 3. End boundary = TaskUpdate(completed) или teamctl(complete)
+ * 2. Start boundary = TaskUpdate(in_progress) или historical teamctl(start)
+ * 3. End boundary = TaskUpdate(completed) или historical teamctl(complete)
* 4. Scope = все tool_use между start.lineNumber и end.lineNumber
* 5. Если single-task session: scope = весь файл
*/
diff --git a/docs/research/diff-view-research.md b/docs/research/diff-view-research.md
index d6257e7d..c79b6c5d 100644
--- a/docs/research/diff-view-research.md
+++ b/docs/research/diff-view-research.md
@@ -559,7 +559,7 @@ EditorView.theme({
- 100% парсируемо — `input.taskId` + `input.status`
- Tool result: `"Updated task #1 status"` (текст)
-**Механизм B: Bash `teamctl.js`** (44 сессии)
+**Механизм B: исторический Bash `teamctl.js`** (44 сессии, legacy)
```bash
node "$HOME/.claude/tools/teamctl.js" --team "" task start|complete|set-status
```
@@ -600,7 +600,7 @@ parseTaskBoundaries(sessionJsonl) → Map
- status == "in_progress" → TASK_START(taskId, line)
- status == "completed" → TASK_END(taskId, line)
-2. Детектировать Bash teamctl:
+2. Детектировать исторические Bash teamctl вызовы:
- "task start " → TASK_START(taskId, line)
- "task complete " → TASK_END(taskId, line)
@@ -625,7 +625,7 @@ parseTaskBoundaries(sessionJsonl) → Map
### Как достичь 95%+
1. **Добавить парсинг `TaskUpdate` tool_use** (name == "TaskUpdate", input.taskId, input.status)
-2. **Сохранить Bash teamctl regex** для остальных 12.5%
+2. **Сохранить regex для исторических Bash teamctl логов** для остальных 12.5%
3. Для single-task сессий (86%): вся сессия = задача (100%)
4. Для multi-task: маркеры start/end как границы сегментов
@@ -644,7 +644,7 @@ parseTaskBoundaries(sessionJsonl) → Map
- Решает проблему subagent'ов без `toolUseResult`
### Per-Task Scoping: Структурные маркеры = 95%+
-- `TaskUpdate` tool_use + Bash teamctl = 100% парсируемые маркеры
+- `TaskUpdate` tool_use + исторические Bash teamctl логи = 100% парсируемые маркеры
- 86% сессий = 1 задача → 100% надёжность
- Улучшение с ~85% (text search) до 95%+ (структурный парсинг)
diff --git a/docs/research/electron-decoupling.md b/docs/research/electron-decoupling.md
new file mode 100644
index 00000000..30485dfa
--- /dev/null
+++ b/docs/research/electron-decoupling.md
@@ -0,0 +1,163 @@
+# Electron Decoupling Audit
+
+> Дата аудита: 2026-03-08
+
+## Executive Summary
+
+Кодовая база **на 68% независима от Electron**. Только 30 из 692 файлов имеют прямые Electron импорты. Миграция оценивается в 4-6 недель, в основном механический рефакторинг.
+
+## Структура кодовой базы
+
+| Категория | Файлов | % | Electron-зависимость |
+|-----------|:------:|:-:|---------------------|
+| Renderer (React) | 473 | 68% | Нет — pure React, работает в браузере |
+| Main (Services) | ~140 | 20% | Минимальная — pure Node.js |
+| Main (IPC handlers) | 22 | 3% | Да — `ipcMain.handle()` |
+| Main (Electron APIs) | 8 | 1% | Да — BrowserWindow, app, dialog, shell |
+| Preload | 2 | 0.3% | Да — contextBridge |
+| Shared (types/utils) | 45 | 6.5% | Нет — полностью agnostic |
+
+## Уже реализованная инфраструктура отвязки
+
+### 1. HTTP Server (Fastify)
+- **Файл**: `src/main/services/infrastructure/HttpServer.ts` (179 LOC)
+- Работает на `127.0.0.1:3456`
+- Раздаёт статику + API роуты
+- CORS настроен для standalone режима
+
+### 2. HTTP Routes (80% покрытие)
+- **Директория**: `src/main/http/`
+- 13 файлов роутов, дублируют IPC handlers:
+ - projects, sessions, search, subagents
+ - config, notifications, utility, validation
+ - ssh, updater, events, schedule
+
+### 3. HttpAPIClient
+- **Файл**: `src/renderer/api/httpClient.ts` (400+ LOC)
+- Полная имплементация `ElectronAPI` интерфейса
+- EventSource (SSE) для real-time событий
+- Fetch для request/response
+
+### 4. Unified API Proxy
+- **Файл**: `src/renderer/api/index.ts`
+- Автоматически переключается между:
+ - `window.electronAPI` (Electron mode)
+ - `HttpAPIClient` (browser mode)
+- Прозрачно для всех компонентов
+
+### 5. Standalone Entry Point
+- **Файл**: `src/main/standalone.ts`
+- Запускает HTTP сервер без Electron
+- Стабит UpdaterService, SshConnectionManager
+- Протестирован и работает
+
+## 30 файлов с Electron импортами
+
+### ipcMain (22 файла)
+```
+src/main/ipc/handlers.ts — оркестратор
+src/main/ipc/cliInstaller.ts
+src/main/ipc/config.ts — + dialog, BrowserWindow
+src/main/ipc/context.ts
+src/main/ipc/editor.ts — + BrowserWindow
+src/main/ipc/extensions.ts
+src/main/ipc/httpServer.ts
+src/main/ipc/notifications.ts
+src/main/ipc/projects.ts
+src/main/ipc/rendererLogs.ts
+src/main/ipc/review.ts
+src/main/ipc/schedule.ts
+src/main/ipc/search.ts
+src/main/ipc/sessions.ts
+src/main/ipc/ssh.ts
+src/main/ipc/subagents.ts
+src/main/ipc/teams.ts — + BrowserWindow, Notification
+src/main/ipc/terminal.ts
+src/main/ipc/updater.ts
+src/main/ipc/utility.ts — + shell
+src/main/ipc/validation.ts
+src/main/ipc/window.ts — + app, BrowserWindow
+```
+
+### Другие Electron API
+```
+src/main/index.ts — app, BrowserWindow, ipcMain
+src/main/utils/pathDecoder.ts — app.getPath('home')
+src/main/services/infrastructure/UpdaterService.ts — BrowserWindow, electron-updater
+src/main/services/infrastructure/NotificationManager.ts — Notification
+src/preload/index.ts — contextBridge, ipcRenderer
+```
+
+## Замена каждого Electron API
+
+| API | Файлов | Замена | Усилия |
+|-----|:------:|--------|:------:|
+| `ipcMain.handle()` | 22 | HTTP роуты (80% уже есть) | 3-4ч |
+| `BrowserWindow` | 6 | Убрать (браузер сам управляет) | 1-2ч |
+| `app` lifecycle | 2 | Прямой запуск Node.js сервера | 4-5ч |
+| `dialog.showOpenDialog()` | 1 | HTML ` ` / env var | 3ч |
+| `shell.openExternal/Path` | 1 | `window.open()` / убрать | 1ч |
+| `Notification` | 2 | Browser Notification API | 2ч |
+| `electron-updater` | 1 | GitHub releases redirect | 2ч |
+| `contextBridge` | 1 | Убрать полностью | 1ч |
+
+## Оценка миграции
+
+| Метрика | Значение |
+|---------|----------|
+| Сложность | 6/10 — механический рефакторинг |
+| Объём работы | 4-6 недель |
+| Вероятность успеха | 95% |
+| Уверенность в оценке | 9/10 |
+
+## Фазы миграции
+
+### Phase 1: Setup (2-3 дня)
+- Рефакторинг `src/main/index.ts` в pure HTTP server bootstrap
+- Удаление Electron app lifecycle
+- HTTP server как primary entry point
+
+### Phase 2: IPC → HTTP (3-4 дня)
+- Завершить оставшиеся HTTP роуты (~20%)
+- Удалить все `ipcMain.handle()` вызовы
+- SSE для event delivery
+
+### Phase 3: Desktop-Only Features (2-3 дня)
+- Убрать auto-updater → version check API
+- Убрать dialog → env var / HTML input
+- Убрать shell → client-side links
+- Browser Notification API
+
+### Phase 4: Build System (2-3 дня)
+- `electron-vite` → стандартный Vite
+- Убрать preload bundling
+- Docker build config
+
+### Phase 5: Testing (2-3 дня)
+- HTTP endpoint coverage
+- Browser compatibility
+- Docker deployment
+
+## Что нельзя заменить (Electron-only)
+- Auto-update бинарных патчей → GitHub releases
+- System tray → убрать
+- Native menu → web context menus
+- System hotkeys → browser keyboard events
+- Native file dialogs → HTML file input
+
+## Build изменения
+
+**Текущий:**
+```json
+"dev": "electron-vite dev",
+"build": "electron-vite build",
+"dist": "electron-builder --mac --win --linux"
+```
+
+**После миграции:**
+```json
+"dev": "tsx src/main/standalone.ts & vite",
+"build": "vite build && tsc --noEmit",
+"start": "node dist/main/index.cjs",
+"docker": "docker build -t claude-teams ."
+```
diff --git a/docs/research/lead-thought-msg-scroll.md b/docs/research/lead-thought-msg-scroll.md
new file mode 100644
index 00000000..bbf42872
--- /dev/null
+++ b/docs/research/lead-thought-msg-scroll.md
@@ -0,0 +1,89 @@
+## Findings: реверс порядка lead thoughts (свежие вверху)
+
+### Затронутые файлы
+
+| Файл | Что менять |
+|------|------------|
+| `LeadThoughtsGroup.tsx` | Основной компонент. Строка 471: `chronologicalThoughts = [...thoughts].reverse()` — убрать reverse (thoughts уже newest-first). Логика автоскрола внутри `LeadThoughtsGroupRow` (строки 570-658). |
+| `ActivityTimeline.tsx` | Pinned thought group (строка 322). Порядок messages — newest-first (desc), thoughts группируются через `groupTimelineItems()`. |
+| `collapseState.ts` | `findNewestMessageIndex()` ищет первый `type: 'message'` — при реверсе внутри группы не затронут. |
+| `AnimatedHeightReveal.tsx` | Не требует изменений — анимирует высоту, а не направление. |
+
+### Архитектура текущего скрола thoughts
+
+**LeadThoughtsGroupRow** имеет свой собственный скрол-контейнер (строки 780-807):
+- `scrollRef` — div с `maxHeight: 200px`, `overflowY: auto`
+- `contentRef` — внутренний div с thoughts
+- Автоскрол к **низу** (`queueScrollSync('bottom')`) — потому что newest внизу
+- `isUserScrolledUpRef` — трекает, отскроллил ли юзер вверх
+- `distanceFromBottomRef` — сохраняет позицию для `preserve` mode
+- `handleScroll` (строка 652) — обновляет `isUserScrolledUpRef` через `AUTO_SCROLL_THRESHOLD = 30px`
+- `handleCollapse` (строка 661) — при Show Less скроллит к `scrollHeight` (к низу)
+- `syncScrollableBody` (строка 598) — оркестрирует: force bottom / preserve / noop
+
+**Рендеринг** (строка 795): `chronologicalThoughts.map()` — oldest-first, newest в конце. Анимация `shouldAnimate` только для последнего (newest).
+
+### Что нужно изменить для реверса
+
+1. **Убрать `.reverse()` на строке 471** — thoughts уже newest-first, рендерить как есть
+2. **Перевернуть автоскрол**: вместо scroll-to-bottom → scroll-to-top (`scrollTop = 0`)
+3. **Анимация нового thought**: `shouldAnimate` для `idx === 0` (вместо `idx === chronologicalThoughts.length - 1`)
+4. **`isUserScrolledUpRef`** → переименовать в `isUserScrolledDownRef`, проверять расстояние от **верха** (`scrollTop > threshold`)
+5. **`queueScrollSync`** — `mode: 'top'` вместо `'bottom'` (`scrollTop = 0`), preserve mode: `scrollTop = distanceFromTopRef.current`
+6. **Show More кнопка** — сейчас внизу (ChevronDown). При реверсе: newest вверху, кнопка "Show more" нужна внизу для загрузки старых thoughts — по сути остаётся на месте
+7. **Show Less** (`handleCollapse`) — скроллит к верху (`scrollTop = 0`) вместо `scrollHeight`
+8. **Divider timestamps** (строка 355-360): `showDivider` при `idx > 0` — работает корректно и при реверсе
+
+### Edge Cases
+
+1. **Новый thought приходит во время скрола вниз к старым** — если юзер прокрутил вниз (к старым), новый thought добавляется вверху. Нужно сохранить `scrollTop` позицию. Решение: `preserve` mode отслеживает `distanceFromTop` вместо `distanceFromBottom`.
+
+2. **Первый thought в пустой группе** — скролл не нужен (нет overflow), анимация fade-in для первого элемента.
+
+3. **Переход collapsed → expanded** — `setExpanded(true)` убирает maxHeight. При реверсе scrollTop=0 уже наверху, без скачков. Проблем нет.
+
+4. **Expanded → collapsed (Show Less)** — сейчас `handleCollapse` скроллит к scrollHeight. При реверсе → скроллить к 0. Плюс `scrollIntoView` на контейнер — остаётся как есть.
+
+5. **Real-time streaming** — thoughts приходят через InboxMessage live updates. Каждый новый thought prepend-ится в начало массива `thoughts[]` (newest-first). При реверсе рендера: новый появляется вверху, анимируется LeadThoughtItem с `shouldAnimate`. Ключевой риск: `AnimatedHeightReveal` wrapper расширяется вниз (grid-template-rows: 0fr→1fr). При insert-е вверху контейнер сдвигает всё вниз → **layout shift**. Mitigation: CSS `overflow-anchor: none` уже стоит (строка 790). Но для items внутри scroll-контейнера нужно `overflow-anchor: auto` на последнем видимом элементе. **Это главный технический риск.**
+
+6. **`getThoughtGroupKey`** (строка 67-70) — использует oldest thought для стабильного ключа. При реверсе rendering порядок меняется, но key остаётся тот же. Проблем нет.
+
+7. **ResizeObserver** (строка 632-637) — наблюдает за contentRef. При реверсе content растёт вверху → ResizeObserver сработает, вызовет syncScrollableBody. Нужно убедиться что `preserve` mode корректно считает offset от верха.
+
+8. **`isBodyVisible` toggle** (collapse mode из ActivityTimeline) — скрывает/показывает body. При реверсе: после re-show нужно скроллить к top (не bottom). Затронуто useLayoutEffect на строке 625-638.
+
+### Аналоги в проекте для референса
+
+- `DisplayItemList.tsx:107` — использует `flex-col-reverse` для newest-first. Простой подход, но не подходит для нашего случая (у нас свой scroll container с автоскролом).
+- `CliLogsRichView.tsx:376` — `[...entries].reverse()` для newest-first порядка.
+
+### Предложенный план реализации
+
+1. В `LeadThoughtsGroupRow`: убрать `chronologicalThoughts` reverse, рендерить `thoughts` напрямую (newest-first)
+2. Перевернуть scroll-sync логику: auto-scroll к `scrollTop=0`, preserve через `distanceFromTop`
+3. Обновить `shouldAnimate` для `idx === 0`
+4. Обновить `handleCollapse` → `scrollTop = 0`
+5. Show More/Show Less кнопки: Show More внизу (для старых thoughts) — без изменений. Show Less — без изменений.
+6. Тестировать layout shift при live streaming
+
+### Оценки
+
+**Сложность реализации: 5/10**
+- Основная логика сосредоточена в одном файле (LeadThoughtsGroup.tsx)
+- Scroll-sync перевернуть — умеренно сложно (6 точек изменения: queueScrollSync, handleScroll, handleCollapse, syncScrollableBody, useLayoutEffect, shouldAnimate)
+- Нет зависимости от внешнего useAutoScrollBottom — LeadThoughtsGroupRow имеет свой собственный scroll management
+- Не нужно трогать groupTimelineItems() или ActivityTimeline
+
+**Уверенность в оценке: 8/10**
+- Код хорошо изолирован — весь scroll management внутри одного компонента
+- Единственный серьёзный риск — layout shift при real-time prepend (edge case #5)
+- CSS overflow-anchor может не работать идеально для insert-вверх в scroll container
+
+### Оценка рисков
+
+| Риск | Вероятность | Последствие | Mitigation |
+|------|-------------|-------------|------------|
+| Layout shift при live streaming | Средняя | Визуальные скачки | overflow-anchor + manual scrollTop adjustment |
+| Broken Show More/Less | Низкая | UX деградация | Простой фикс scrollTop |
+| Regression в collapsed mode | Низкая | Thoughts не видны | Не затрагивает collapse logic |
+| Browser inconsistency overflow-anchor | Низкая | Скачки в Safari | Fallback: manual scroll compensation |
\ No newline at end of file
diff --git a/docs/research/markdown-rendering-pipeline.md b/docs/research/markdown-rendering-pipeline.md
index 66e4a670..0b087a36 100644
--- a/docs/research/markdown-rendering-pipeline.md
+++ b/docs/research/markdown-rendering-pipeline.md
@@ -96,8 +96,8 @@ From `MarkdownViewer.tsx` (lines 561-571):
### Identified scenarios
-1. **Task descriptions from CLI tooling (teamctl.js / Claude agents)**
- Task descriptions are set via `node teamctl.js task create` or `TaskCreate` tool calls. Agents typically write plain text descriptions, not markdown. The description content itself lacks formatting — MarkdownViewer renders it correctly, but there's nothing to format.
+1. **Task descriptions from task tooling / Claude agents**
+ Historically this included `teamctl.js`; the current architecture uses controller/MCP-based task operations. In both cases, agents typically write plain text descriptions, not markdown. The description content itself lacks formatting — MarkdownViewer renders it correctly, but there's nothing to format.
2. **Task comments from agents**
Same issue — `task comment --text "..."` passes plain text. However, TaskCommentsSection.tsx (line 246) correctly uses ` `.
@@ -125,7 +125,7 @@ From `MarkdownViewer.tsx` (lines 561-571):
The pipeline is correct. All text display surfaces that show long-form content already use MarkdownViewer.
### Fix 2: If specific content appears unformatted, the fix is upstream
-Ensure that agents/tooling that create tasks or comments use markdown formatting in their text. For example, the `teamctl.js` task create command could document that `--description` supports markdown.
+Ensure that agents/tooling that create tasks or comments use markdown formatting in their text. In the current architecture, this guidance applies to controller/MCP-backed task creation rather than the removed `teamctl.js` CLI.
### Fix 3 (Optional): ReplyQuoteBlock markdown support
`ReplyQuoteBlock` renders the reply body. If it currently shows plain text, wrap body content in ` ` for consistency. (Needs verification — separate from this research scope.)
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 485af723..579bf168 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -12,7 +12,7 @@ const prodDeps = Object.keys(pkg.dependencies || {})
// node-pty is a native addon that cannot be bundled by Rollup.
// It must remain external and be loaded at runtime via require().
-const bundledDeps = prodDeps.filter(d => d !== 'node-pty')
+const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-controller')
// Rollup plugin: stub out native .node addon imports with empty modules.
// ssh2 and cpu-features use optional native bindings that can't be bundled,
diff --git a/mcp-server/package.json b/mcp-server/package.json
index b16663fa..6a16ddb1 100644
--- a/mcp-server/package.json
+++ b/mcp-server/package.json
@@ -1,7 +1,7 @@
{
"name": "agent-teams-mcp",
"version": "1.0.0",
- "description": "MCP server for managing Claude Agent Teams kanban board and tasks via teamctl CLI",
+ "description": "MCP server for managing Claude Agent Teams through the agent-teams-controller API",
"type": "module",
"main": "dist/index.js",
"bin": {
@@ -27,12 +27,16 @@
"scripts": {
"build": "tsup",
"dev": "tsx src/index.ts",
+ "lint": "eslint \"src/**/*.ts\"",
"test": "vitest run",
+ "test:e2e": "pnpm build && vitest run test/stdio.e2e.test.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
+ "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
"prepublishOnly": "pnpm build"
},
"dependencies": {
+ "agent-teams-controller": "workspace:*",
"fastmcp": "^3.34.0",
"zod": "^4.3.6"
},
diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts
new file mode 100644
index 00000000..04043d6f
--- /dev/null
+++ b/mcp-server/src/agent-teams-controller.d.ts
@@ -0,0 +1,80 @@
+declare module 'agent-teams-controller' {
+ export interface ControllerContextOptions {
+ teamName: string;
+ claudeDir?: string;
+ }
+
+ export interface ControllerTaskApi {
+ createTask(flags: Record): unknown;
+ getTask(taskId: string): unknown;
+ listTasks(): unknown[];
+ listDeletedTasks(): unknown[];
+ resolveTaskId(taskRef: string): string;
+ setTaskStatus(taskId: string, status: string, actor?: string): unknown;
+ startTask(taskId: string, actor?: string): unknown;
+ completeTask(taskId: string, actor?: string): unknown;
+ softDeleteTask(taskId: string, actor?: string): unknown;
+ restoreTask(taskId: string, actor?: string): unknown;
+ setTaskOwner(taskId: string, owner: string | null): unknown;
+ updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
+ addTaskComment(taskId: string, flags: Record): unknown;
+ attachTaskFile(taskId: string, flags: Record): unknown;
+ attachCommentFile(taskId: string, commentId: string, flags: Record): unknown;
+ addTaskAttachmentMeta(taskId: string, meta: Record): unknown;
+ removeTaskAttachment(taskId: string, attachmentId: string): unknown;
+ setNeedsClarification(taskId: string, value: string | null): unknown;
+ linkTask(taskId: string, targetId: string, linkType: string): unknown;
+ unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
+ taskBriefing(memberName: string): Promise;
+ }
+
+ export interface ControllerKanbanApi {
+ getKanbanState(): unknown;
+ setKanbanColumn(taskId: string, column: string): unknown;
+ clearKanban(taskId: string): unknown;
+ listReviewers(): string[];
+ addReviewer(reviewer: string): string[];
+ removeReviewer(reviewer: string): string[];
+ updateColumnOrder(columnId: string, orderedTaskIds: string[]): unknown;
+ }
+
+ export interface ControllerReviewApi {
+ requestReview(taskId: string, flags?: Record): unknown;
+ approveReview(taskId: string, flags?: Record): unknown;
+ requestChanges(taskId: string, flags?: Record): unknown;
+ }
+
+ export interface ControllerMessageApi {
+ appendSentMessage(flags: Record): unknown;
+ sendMessage(flags: Record): unknown;
+ }
+
+ export interface ControllerProcessApi {
+ registerProcess(flags: Record): unknown;
+ stopProcess(flags: Record): unknown;
+ unregisterProcess(flags: Record): unknown;
+ listProcesses(): unknown[];
+ }
+
+ export interface ControllerMaintenanceApi {
+ reconcileArtifacts(flags?: Record): unknown;
+ }
+
+ export interface ControllerCrossTeamApi {
+ sendCrossTeamMessage(flags: Record): unknown;
+ listCrossTeamTargets(flags?: Record): unknown;
+ getCrossTeamOutbox(): unknown;
+ }
+
+ export interface AgentTeamsController {
+ tasks: ControllerTaskApi;
+ kanban: ControllerKanbanApi;
+ review: ControllerReviewApi;
+ messages: ControllerMessageApi;
+ processes: ControllerProcessApi;
+ maintenance: ControllerMaintenanceApi;
+ crossTeam: ControllerCrossTeamApi;
+ }
+
+ export function createController(options: ControllerContextOptions): AgentTeamsController;
+}
diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts
new file mode 100644
index 00000000..26dce9b6
--- /dev/null
+++ b/mcp-server/src/controller.ts
@@ -0,0 +1,16 @@
+import * as agentTeamsControllerModule from 'agent-teams-controller';
+
+type ControllerModule = typeof import('agent-teams-controller') & {
+ default?: typeof import('agent-teams-controller');
+};
+
+const controllerModule =
+ (agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule;
+const { createController } = controllerModule;
+
+export function getController(teamName: string, claudeDir?: string) {
+ return createController({
+ teamName,
+ ...(claudeDir ? { claudeDir } : {}),
+ });
+}
diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts
new file mode 100644
index 00000000..0ca07344
--- /dev/null
+++ b/mcp-server/src/index.ts
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+import { pathToFileURL } from 'node:url';
+
+import { FastMCP } from 'fastmcp';
+
+import { registerTools } from './tools';
+
+export function createServer() {
+ const server = new FastMCP({
+ name: 'agent-teams-mcp',
+ version: '1.0.0',
+ });
+
+ registerTools(server);
+
+ return server;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const server = createServer();
+ void server.start({
+ transportType: 'stdio',
+ });
+}
diff --git a/mcp-server/src/tools/crossTeamTools.ts b/mcp-server/src/tools/crossTeamTools.ts
new file mode 100644
index 00000000..486eb014
--- /dev/null
+++ b/mcp-server/src/tools/crossTeamTools.ts
@@ -0,0 +1,81 @@
+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) {
+ 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(),
+ conversationId: z.string().optional(),
+ replyToConversationId: z.string().optional(),
+ chainDepth: z.number().int().nonnegative().optional(),
+ }),
+ execute: async ({
+ teamName,
+ claudeDir,
+ toTeam,
+ text,
+ fromMember,
+ summary,
+ conversationId,
+ replyToConversationId,
+ chainDepth,
+ }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
+ toTeam,
+ text,
+ ...(fromMember ? { fromMember } : {}),
+ ...(summary ? { summary } : {}),
+ ...(conversationId ? { conversationId } : {}),
+ ...(replyToConversationId ? { replyToConversationId } : {}),
+ ...(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())
+ ),
+ });
+}
diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts
new file mode 100644
index 00000000..755b24f7
--- /dev/null
+++ b/mcp-server/src/tools/index.ts
@@ -0,0 +1,17 @@
+import type { FastMCP } from 'fastmcp';
+
+import { registerCrossTeamTools } from './crossTeamTools';
+import { registerKanbanTools } from './kanbanTools';
+import { registerMessageTools } from './messageTools';
+import { registerProcessTools } from './processTools';
+import { registerReviewTools } from './reviewTools';
+import { registerTaskTools } from './taskTools';
+
+export function registerTools(server: FastMCP) {
+ registerTaskTools(server);
+ registerKanbanTools(server);
+ registerReviewTools(server);
+ registerMessageTools(server);
+ registerProcessTools(server);
+ registerCrossTeamTools(server);
+}
diff --git a/mcp-server/src/tools/kanbanTools.ts b/mcp-server/src/tools/kanbanTools.ts
new file mode 100644
index 00000000..a086db38
--- /dev/null
+++ b/mcp-server/src/tools/kanbanTools.ts
@@ -0,0 +1,81 @@
+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 registerKanbanTools(server: Pick) {
+ server.addTool({
+ name: 'kanban_get',
+ description: 'Get current kanban state',
+ parameters: z.object({
+ ...toolContextSchema,
+ }),
+ execute: async ({ teamName, claudeDir }) =>
+ await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
+ });
+
+ server.addTool({
+ name: 'kanban_set_column',
+ description: 'Move task to review or approved column',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ column: z.enum(['review', 'approved']),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, column }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
+ ),
+ });
+
+ server.addTool({
+ name: 'kanban_clear',
+ description: 'Remove task from kanban board',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ }),
+ execute: async ({ teamName, claudeDir, taskId }) =>
+ await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
+ });
+
+ server.addTool({
+ name: 'kanban_list_reviewers',
+ description: 'List configured review participants',
+ parameters: z.object({
+ ...toolContextSchema,
+ }),
+ execute: async ({ teamName, claudeDir }) =>
+ await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
+ });
+
+ server.addTool({
+ name: 'kanban_add_reviewer',
+ description: 'Add a reviewer to kanban configuration',
+ parameters: z.object({
+ ...toolContextSchema,
+ reviewer: z.string().min(1),
+ }),
+ execute: async ({ teamName, claudeDir, reviewer }) =>
+ await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
+ });
+
+ server.addTool({
+ name: 'kanban_remove_reviewer',
+ description: 'Remove reviewer from kanban configuration',
+ parameters: z.object({
+ ...toolContextSchema,
+ reviewer: z.string().min(1),
+ }),
+ execute: async ({ teamName, claudeDir, reviewer }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
+ ),
+ });
+}
diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts
new file mode 100644
index 00000000..59030abf
--- /dev/null
+++ b/mcp-server/src/tools/messageTools.ts
@@ -0,0 +1,60 @@
+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 registerMessageTools(server: Pick) {
+ server.addTool({
+ name: 'message_send',
+ description: 'Send a message into team inbox',
+ parameters: z.object({
+ ...toolContextSchema,
+ to: z.string().min(1),
+ text: z.string().min(1),
+ from: z.string().optional(),
+ summary: z.string().optional(),
+ source: z.string().optional(),
+ leadSessionId: z.string().optional(),
+ attachments: z
+ .array(
+ z.object({
+ id: z.string().min(1),
+ filename: z.string().min(1),
+ mimeType: z.string().min(1),
+ size: z.number().nonnegative(),
+ })
+ )
+ .optional(),
+ }),
+ execute: async ({
+ teamName,
+ claudeDir,
+ to,
+ text,
+ from,
+ summary,
+ source,
+ leadSessionId,
+ attachments,
+ }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).messages.sendMessage({
+ to,
+ text,
+ ...(from ? { from } : {}),
+ ...(summary ? { summary } : {}),
+ ...(source ? { source } : {}),
+ ...(leadSessionId ? { leadSessionId } : {}),
+ ...(attachments?.length ? { attachments } : {}),
+ })
+ )
+ ),
+ });
+}
diff --git a/mcp-server/src/tools/processTools.ts b/mcp-server/src/tools/processTools.ts
new file mode 100644
index 00000000..4c682835
--- /dev/null
+++ b/mcp-server/src/tools/processTools.ts
@@ -0,0 +1,89 @@
+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 registerProcessTools(server: Pick) {
+ server.addTool({
+ name: 'process_register',
+ description: 'Register a running process for a team member',
+ parameters: z.object({
+ ...toolContextSchema,
+ pid: z.number().int().positive(),
+ label: z.string().min(1),
+ from: z.string().optional(),
+ command: z.string().min(1).optional(),
+ port: z.number().int().min(1).max(65535).optional(),
+ url: z.string().min(1).optional(),
+ claudeProcessId: z.string().min(1).optional(),
+ }),
+ execute: async ({
+ teamName,
+ claudeDir,
+ pid,
+ label,
+ from,
+ command,
+ port,
+ url,
+ claudeProcessId,
+ }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).processes.registerProcess({
+ pid,
+ label,
+ ...(from ? { from } : {}),
+ ...(command ? { command } : {}),
+ ...(port ? { port } : {}),
+ ...(url ? { url } : {}),
+ ...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
+ })
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'process_list',
+ description: 'List registered team processes',
+ parameters: z.object({
+ ...toolContextSchema,
+ }),
+ execute: async ({ teamName, claudeDir }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
+ ),
+ });
+
+ server.addTool({
+ name: 'process_unregister',
+ description: 'Unregister a previously registered process',
+ parameters: z.object({
+ ...toolContextSchema,
+ pid: z.number().int().positive(),
+ }),
+ execute: async ({ teamName, claudeDir, pid }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
+ ),
+ });
+
+ server.addTool({
+ name: 'process_stop',
+ description: 'Mark a registered process as stopped while preserving history',
+ parameters: z.object({
+ ...toolContextSchema,
+ pid: z.number().int().positive(),
+ }),
+ execute: async ({ teamName, claudeDir, pid }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
+ ),
+ });
+}
diff --git a/mcp-server/src/tools/reviewTools.ts b/mcp-server/src/tools/reviewTools.ts
new file mode 100644
index 00000000..08293ca4
--- /dev/null
+++ b/mcp-server/src/tools/reviewTools.ts
@@ -0,0 +1,80 @@
+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 registerReviewTools(server: Pick) {
+ server.addTool({
+ name: 'review_request',
+ description: 'Move a completed task into review and notify reviewer',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ from: z.string().optional(),
+ reviewer: z.string().optional(),
+ leadSessionId: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).review.requestReview(taskId, {
+ ...(from ? { from } : {}),
+ ...(reviewer ? { reviewer } : {}),
+ ...(leadSessionId ? { leadSessionId } : {}),
+ })
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'review_approve',
+ description: 'Approve task review and move kanban state accordingly',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ from: z.string().optional(),
+ note: z.string().optional(),
+ notifyOwner: z.boolean().optional(),
+ leadSessionId: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).review.approveReview(taskId, {
+ ...(from ? { from } : {}),
+ ...(note ? { note } : {}),
+ ...(notifyOwner !== false ? { 'notify-owner': true } : {}),
+ ...(leadSessionId ? { leadSessionId } : {}),
+ })
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'review_request_changes',
+ description: 'Request changes on a task under review',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ from: z.string().optional(),
+ comment: z.string().optional(),
+ leadSessionId: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).review.requestChanges(taskId, {
+ ...(from ? { from } : {}),
+ ...(comment ? { comment } : {}),
+ ...(leadSessionId ? { leadSessionId } : {}),
+ })
+ )
+ ),
+ });
+}
diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts
new file mode 100644
index 00000000..94bdaf07
--- /dev/null
+++ b/mcp-server/src/tools/taskTools.ts
@@ -0,0 +1,281 @@
+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(),
+};
+
+const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
+
+export function registerTaskTools(server: Pick) {
+ server.addTool({
+ name: 'task_create',
+ description: 'Create a team task',
+ parameters: z.object({
+ ...toolContextSchema,
+ subject: z.string().min(1),
+ description: z.string().optional(),
+ owner: z.string().optional(),
+ blockedBy: z.array(z.string().min(1)).optional(),
+ related: z.array(z.string().min(1)).optional(),
+ prompt: z.string().optional(),
+ startImmediately: z.boolean().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, subject, description, owner, blockedBy, related, prompt, startImmediately }) => {
+ const controller = getController(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(
+ controller.tasks.createTask({
+ subject,
+ ...(description ? { description } : {}),
+ ...(owner ? { owner } : {}),
+ ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
+ ...(related?.length ? { related: related.join(',') } : {}),
+ ...(prompt ? { prompt } : {}),
+ ...(startImmediately !== undefined ? { startImmediately } : {}),
+ })
+ )
+ );
+ },
+ });
+
+ server.addTool({
+ name: 'task_get',
+ description: 'Get a task by id',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ }),
+ execute: async ({ teamName, claudeDir, taskId }) =>
+ await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
+ });
+
+ server.addTool({
+ name: 'task_list',
+ description: 'List tasks for a team',
+ parameters: z.object({
+ ...toolContextSchema,
+ }),
+ execute: async ({ teamName, claudeDir }) =>
+ await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.listTasks())),
+ });
+
+ server.addTool({
+ name: 'task_set_status',
+ description: 'Set task work status',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
+ actor: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor))
+ ),
+ });
+
+ server.addTool({
+ name: 'task_start',
+ description: 'Mark task as in progress',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ actor: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, actor }) =>
+ await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.startTask(taskId, actor))),
+ });
+
+ server.addTool({
+ name: 'task_complete',
+ description: 'Mark task as completed',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ actor: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, actor }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).tasks.completeTask(taskId, actor))
+ ),
+ });
+
+ server.addTool({
+ name: 'task_set_owner',
+ description: 'Assign or clear task owner',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ owner: z.string().nullable(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, owner }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner))
+ ),
+ });
+
+ server.addTool({
+ name: 'task_add_comment',
+ description: 'Add task comment',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ text: z.string().min(1),
+ from: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, text, from }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
+ text,
+ ...(from ? { from } : {}),
+ })
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'task_attach_file',
+ description: 'Attach a file to a task',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ filePath: z.string().min(1),
+ mode: z.enum(['copy', 'link']).optional(),
+ filename: z.string().optional(),
+ mimeType: z.string().optional(),
+ noFallback: z.boolean().optional(),
+ }),
+ execute: async ({
+ teamName,
+ claudeDir,
+ taskId,
+ filePath,
+ mode,
+ filename,
+ mimeType,
+ noFallback,
+ }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
+ file: filePath,
+ ...(mode ? { mode } : {}),
+ ...(filename ? { filename } : {}),
+ ...(mimeType ? { 'mime-type': mimeType } : {}),
+ ...(noFallback ? { 'no-fallback': true } : {}),
+ })
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'task_attach_comment_file',
+ description: 'Attach a file to a task comment',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ commentId: z.string().min(1),
+ filePath: z.string().min(1),
+ mode: z.enum(['copy', 'link']).optional(),
+ filename: z.string().optional(),
+ mimeType: z.string().optional(),
+ noFallback: z.boolean().optional(),
+ }),
+ execute: async ({
+ teamName,
+ claudeDir,
+ taskId,
+ commentId,
+ filePath,
+ mode,
+ filename,
+ mimeType,
+ noFallback,
+ }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
+ file: filePath,
+ ...(mode ? { mode } : {}),
+ ...(filename ? { filename } : {}),
+ ...(mimeType ? { 'mime-type': mimeType } : {}),
+ ...(noFallback ? { 'no-fallback': true } : {}),
+ })
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'task_set_clarification',
+ description: 'Set or clear task clarification state',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ value: z.enum(['lead', 'user', 'clear']),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, value }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).tasks.setNeedsClarification(
+ taskId,
+ value === 'clear' ? null : value
+ )
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'task_link',
+ description: 'Link tasks by blockedBy, blocks, or related relationship',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ targetId: z.string().min(1),
+ relationship: relationshipTypeSchema,
+ }),
+ execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
+ await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship))
+ ),
+ });
+
+ server.addTool({
+ name: 'task_unlink',
+ description: 'Remove task relationship link',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ targetId: z.string().min(1),
+ relationship: relationshipTypeSchema,
+ }),
+ execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
+ await Promise.resolve(
+ jsonTextContent(
+ getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship)
+ )
+ ),
+ });
+
+ server.addTool({
+ name: 'task_briefing',
+ description: 'Get formatted task briefing for a member',
+ parameters: z.object({
+ ...toolContextSchema,
+ memberName: z.string().min(1),
+ }),
+ execute: async ({ teamName, claudeDir, memberName }) => ({
+ content: [
+ {
+ type: 'text' as const,
+ text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
+ },
+ ],
+ }),
+ });
+}
diff --git a/mcp-server/src/utils/format.ts b/mcp-server/src/utils/format.ts
new file mode 100644
index 00000000..ce7873b4
--- /dev/null
+++ b/mcp-server/src/utils/format.ts
@@ -0,0 +1,10 @@
+export function jsonTextContent(value: unknown): { content: { type: 'text'; text: string }[] } {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify(value, null, 2),
+ },
+ ],
+ };
+}
diff --git a/mcp-server/test/stdio.e2e.test.ts b/mcp-server/test/stdio.e2e.test.ts
new file mode 100644
index 00000000..a988a07d
--- /dev/null
+++ b/mcp-server/test/stdio.e2e.test.ts
@@ -0,0 +1,154 @@
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+function parseJsonToolResult(result: unknown) {
+ const text = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text;
+ return JSON.parse(text ?? 'null');
+}
+
+class McpStdIoClient {
+ private readonly child: ChildProcessWithoutNullStreams;
+ private stdoutBuffer = '';
+
+ constructor(serverPath: string, cwd: string) {
+ this.child = spawn('node', [serverPath], {
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+
+ this.child.stdout.setEncoding('utf8');
+ this.child.stdout.on('data', (chunk: string) => {
+ this.stdoutBuffer += chunk;
+ });
+ }
+
+ async initialize() {
+ const response = await this.request(1, 'initialize', {
+ protocolVersion: '2024-11-05',
+ capabilities: {},
+ clientInfo: { name: 'vitest-e2e', version: '1.0.0' },
+ });
+
+ this.notify('notifications/initialized');
+ return response;
+ }
+
+ async listTools() {
+ return this.request(2, 'tools/list', {});
+ }
+
+ async callTool(name: string, args: Record, id = 3) {
+ return this.request(id, 'tools/call', { name, arguments: args });
+ }
+
+ async close() {
+ this.child.kill('SIGTERM');
+ await new Promise((resolve) => {
+ this.child.once('exit', () => resolve());
+ setTimeout(() => resolve(), 1000).unref();
+ });
+ }
+
+ private notify(method: string, params?: Record) {
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`);
+ }
+
+ private async request(id: number, method: string, params: Record) {
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
+ return this.readMessage(id);
+ }
+
+ private async readMessage(expectedId: number) {
+ const deadline = Date.now() + 5000;
+
+ while (Date.now() < deadline) {
+ const newlineIndex = this.stdoutBuffer.indexOf('\n');
+ if (newlineIndex !== -1) {
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
+
+ if (!line) {
+ continue;
+ }
+
+ const parsed = JSON.parse(line) as { id?: number };
+ if (parsed.id === expectedId) {
+ return parsed;
+ }
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ }
+
+ throw new Error(`Timed out waiting for MCP response ${expectedId}`);
+ }
+}
+
+describe('agent-teams-mcp stdio e2e', () => {
+ const serverPath = fileURLToPath(new URL('../dist/index.js', import.meta.url));
+ const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url));
+
+ let claudeDir: string;
+
+ beforeEach(async () => {
+ claudeDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-e2e-'));
+ });
+
+ afterEach(async () => {
+ await rm(claudeDir, { recursive: true, force: true });
+ });
+
+ it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => {
+ const client = new McpStdIoClient(serverPath, workspaceRoot);
+
+ try {
+ const init = await client.initialize();
+ expect(init).toHaveProperty('result');
+
+ const tools = (await client.listTools()) as {
+ result?: { tools?: Array<{ name: string }> };
+ };
+ const toolNames = (tools.result?.tools ?? []).map((tool) => tool.name);
+
+ expect(toolNames).toContain('task_create');
+ expect(toolNames).toContain('task_start');
+ expect(toolNames).toContain('review_approve');
+
+ const createResult = await client.callTool(
+ 'task_create',
+ {
+ claudeDir,
+ teamName: 'e2e-team',
+ subject: 'Smoke task',
+ owner: 'alice',
+ },
+ 3
+ );
+ const createdTask = parseJsonToolResult((createResult as { result: unknown }).result);
+
+ expect(createdTask.subject).toBe('Smoke task');
+ expect(createdTask.owner).toBe('alice');
+ expect(typeof createdTask.id).toBe('string');
+
+ const startResult = await client.callTool(
+ 'task_start',
+ {
+ claudeDir,
+ teamName: 'e2e-team',
+ taskId: createdTask.id,
+ actor: 'alice',
+ },
+ 4
+ );
+ const startedTask = parseJsonToolResult((startResult as { result: unknown }).result);
+
+ expect(startedTask.status).toBe('in_progress');
+ expect(startedTask.id).toBe(createdTask.id);
+ } finally {
+ await client.close();
+ }
+ });
+});
diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts
new file mode 100644
index 00000000..72e79e79
--- /dev/null
+++ b/mcp-server/test/tools.test.ts
@@ -0,0 +1,613 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+import { registerTools } from '../src/tools';
+
+type RegisteredTool = {
+ name: string;
+ parameters?: { safeParse: (value: unknown) => { success: boolean } };
+ execute: (args: Record) => Promise | unknown;
+};
+
+function collectTools() {
+ const tools = new Map();
+
+ registerTools({
+ addTool(config: RegisteredTool) {
+ tools.set(config.name, config);
+ },
+ } as never);
+
+ return tools;
+}
+
+function parseJsonToolResult(result: unknown) {
+ const text = (result as { content: Array<{ text: string }> }).content[0]?.text;
+ return JSON.parse(text);
+}
+
+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',
+ 'kanban_list_reviewers',
+ 'kanban_remove_reviewer',
+ 'kanban_set_column',
+ 'message_send',
+ 'process_list',
+ 'process_register',
+ 'process_stop',
+ 'process_unregister',
+ 'review_approve',
+ 'review_request',
+ 'review_request_changes',
+ 'task_add_comment',
+ 'task_attach_comment_file',
+ 'task_attach_file',
+ 'task_briefing',
+ 'task_complete',
+ 'task_create',
+ 'task_get',
+ 'task_link',
+ 'task_list',
+ 'task_set_clarification',
+ 'task_set_owner',
+ 'task_set_status',
+ 'task_start',
+ 'task_unlink',
+ ] as const;
+
+ function getTool(name: string) {
+ const tool = tools.get(name);
+ expect(tool).toBeDefined();
+ return tool!;
+ }
+
+ function makeClaudeDir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
+ }
+
+ it('registers the full expected MCP tool surface', () => {
+ expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
+ });
+
+ it('accepts explicit conversation threading fields for cross_team_send', () => {
+ const parsed = getTool('cross_team_send').parameters?.safeParse({
+ teamName: 'alpha',
+ toTeam: 'beta',
+ text: 'Reply',
+ conversationId: 'conv-1',
+ replyToConversationId: 'conv-1',
+ });
+
+ expect(parsed?.success).toBe(true);
+ });
+
+ it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
+ const claudeDir = makeClaudeDir();
+ const teamName = 'alpha';
+ const attachmentPath = path.join(claudeDir, 'note.txt');
+ fs.writeFileSync(attachmentPath, 'ship it');
+
+ const dependencyTask = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Dependency',
+ })
+ );
+
+ const createdTask = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Review MCP adapter',
+ owner: 'alice',
+ })
+ );
+ expect(createdTask.status).toBe('pending');
+
+ const listedTasks = parseJsonToolResult(
+ await getTool('task_list').execute({
+ claudeDir,
+ teamName,
+ })
+ );
+ expect(listedTasks).toHaveLength(2);
+
+ const linked = parseJsonToolResult(
+ await getTool('task_link').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ targetId: dependencyTask.id,
+ relationship: 'blocked-by',
+ })
+ );
+ expect(linked.blockedBy).toContain(dependencyTask.id);
+
+ const unlinked = parseJsonToolResult(
+ await getTool('task_unlink').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ targetId: dependencyTask.id,
+ relationship: 'blocked-by',
+ })
+ );
+ expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id);
+
+ const owned = parseJsonToolResult(
+ await getTool('task_set_owner').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ owner: 'alice',
+ })
+ );
+ expect(owned.owner).toBe('alice');
+
+ const commented = parseJsonToolResult(
+ await getTool('task_add_comment').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ text: 'Need one more check',
+ from: 'lead',
+ })
+ );
+
+ const commentId = commented.commentId;
+ expect(commentId).toBeTruthy();
+
+ const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
+ const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
+ expect(ownerInbox.at(-1).summary).toContain(`#${createdTask.displayId}`);
+ expect(ownerInbox.at(-1).text).toContain('Need one more check');
+
+ const attachment = parseJsonToolResult(
+ await getTool('task_attach_comment_file').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ commentId,
+ filePath: attachmentPath,
+ mode: 'copy',
+ })
+ );
+
+ expect(attachment.filename).toBe('note.txt');
+
+ const taskAttachment = parseJsonToolResult(
+ await getTool('task_attach_file').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ filePath: attachmentPath,
+ mode: 'copy',
+ })
+ );
+ expect(taskAttachment.filename).toBe('note.txt');
+
+ await getTool('task_set_clarification').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ value: 'user',
+ });
+
+ const loadedTask = parseJsonToolResult(
+ await getTool('task_get').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ })
+ );
+
+ expect(loadedTask.needsClarification).toBe('user');
+ expect(loadedTask.comments).toHaveLength(1);
+ expect(loadedTask.comments[0].attachments).toHaveLength(1);
+ expect(loadedTask.attachments).toHaveLength(1);
+
+ const started = parseJsonToolResult(
+ await getTool('task_start').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ actor: 'alice',
+ })
+ );
+ expect(started.status).toBe('in_progress');
+
+ await getTool('task_set_status').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ status: 'completed',
+ });
+
+ parseJsonToolResult(
+ await getTool('kanban_add_reviewer').execute({
+ claudeDir,
+ teamName,
+ reviewer: 'alice',
+ })
+ );
+ const reviewers = parseJsonToolResult(
+ await getTool('kanban_list_reviewers').execute({
+ claudeDir,
+ teamName,
+ })
+ );
+ expect(reviewers).toEqual(['alice']);
+
+ const reviewRequested = parseJsonToolResult(
+ await getTool('review_request').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ from: 'lead',
+ reviewer: 'alice',
+ leadSessionId: 'session-review-1',
+ })
+ );
+
+ expect(reviewRequested.reviewState).toBe('review');
+ const reviewerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
+ const reviewerInbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
+ expect(reviewerInbox.at(-1).leadSessionId).toBe('session-review-1');
+
+ const approved = parseJsonToolResult(
+ await getTool('review_approve').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ from: 'lead',
+ note: 'Looks good',
+ notifyOwner: true,
+ leadSessionId: 'session-review-1',
+ })
+ );
+ expect(approved.reviewState).toBe('approved');
+ {
+ const approvedInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
+ const approvedInbox = JSON.parse(fs.readFileSync(approvedInboxPath, 'utf8'));
+ expect(approvedInbox.at(-1).leadSessionId).toBe('session-review-1');
+ }
+
+ const kanbanState = parseJsonToolResult(
+ await getTool('kanban_get').execute({
+ claudeDir,
+ teamName,
+ })
+ );
+ expect(kanbanState.tasks[createdTask.id].column).toBe('approved');
+
+ const briefing = await getTool('task_briefing').execute({
+ claudeDir,
+ teamName,
+ memberName: 'alice',
+ });
+ expect((briefing as { content: Array<{ text: string }> }).content[0]?.text).toContain(
+ 'Review MCP adapter'
+ );
+ });
+
+ it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => {
+ const claudeDir = makeClaudeDir();
+ const teamName = 'gamma';
+
+ const queuedTask = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Queued work',
+ description: 'Pending description should stay out of briefing details',
+ owner: 'alice',
+ prompt: 'Read the plan before starting.',
+ })
+ );
+ expect(queuedTask.status).toBe('pending');
+
+ const activeTask = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Active work',
+ description: 'This one is already in progress',
+ owner: 'alice',
+ startImmediately: true,
+ })
+ );
+ expect(activeTask.status).toBe('in_progress');
+
+ await getTool('task_add_comment').execute({
+ claudeDir,
+ teamName,
+ taskId: activeTask.id,
+ text: 'Investigating the active task now.',
+ from: 'alice',
+ });
+
+ const completedTask = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Done work',
+ description: 'Completed description should also stay compact',
+ owner: 'alice',
+ })
+ );
+ await getTool('task_complete').execute({
+ claudeDir,
+ teamName,
+ taskId: completedTask.id,
+ actor: 'alice',
+ });
+
+ const unassignedTask = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Assign later',
+ })
+ );
+ await getTool('task_set_owner').execute({
+ claudeDir,
+ teamName,
+ taskId: unassignedTask.id,
+ owner: 'alice',
+ });
+
+ const queuedByHashRef = parseJsonToolResult(
+ await getTool('task_get').execute({
+ claudeDir,
+ teamName,
+ taskId: `#${queuedTask.displayId}`,
+ })
+ );
+ expect(queuedByHashRef.id).toBe(queuedTask.id);
+
+ const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
+ const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
+ expect(ownerInbox).toHaveLength(4);
+ expect(ownerInbox[0].summary).toContain(`#${queuedTask.displayId}`);
+ expect(ownerInbox[0].text).toContain('task_get');
+ expect(ownerInbox[0].text).toContain('task_start');
+ expect(ownerInbox[0].text).toContain('Read the plan before starting.');
+ expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
+
+ const briefing = (await getTool('task_briefing').execute({
+ claudeDir,
+ teamName,
+ memberName: 'alice',
+ })) as { content: Array<{ text: string }> };
+ const briefingText = briefing.content[0]?.text ?? '';
+ expect(briefingText).toContain('In progress:');
+ expect(briefingText).toContain(`#${activeTask.displayId}`);
+ expect(briefingText).toContain('Description: This one is already in progress');
+ expect(briefingText).toContain('Investigating the active task now.');
+ expect(briefingText).toContain('Pending:');
+ expect(briefingText).toContain(`#${queuedTask.displayId}`);
+ expect(briefingText).not.toContain('Pending description should stay out of briefing details');
+ expect(briefingText).toContain('Completed:');
+ expect(briefingText).toContain(`#${completedTask.displayId}`);
+ expect(briefingText).not.toContain('Completed description should also stay compact');
+ });
+
+ it('covers review_request_changes and full process lifecycle tools', async () => {
+ const claudeDir = makeClaudeDir();
+ const teamName = 'beta';
+
+ const createdTask = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Needs revision',
+ owner: 'bob',
+ })
+ );
+
+ await getTool('task_complete').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ actor: 'bob',
+ });
+
+ await getTool('review_request').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ from: 'lead',
+ reviewer: 'alice',
+ leadSessionId: 'session-review-2',
+ });
+
+ const changesRequested = parseJsonToolResult(
+ await getTool('review_request_changes').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ from: 'alice',
+ comment: 'Please revise this section.',
+ leadSessionId: 'session-review-2',
+ })
+ );
+
+ expect(changesRequested.status).toBe('pending');
+ expect(changesRequested.reviewState).toBe('needsFix');
+ const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json');
+ const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
+ expect(ownerInbox.at(-1).leadSessionId).toBe('session-review-2');
+ expect(ownerInbox.at(-1).text).toContain('moved back to pending');
+
+ const taskByHashRef = parseJsonToolResult(
+ await getTool('task_get').execute({
+ claudeDir,
+ teamName,
+ taskId: `#${createdTask.displayId}`,
+ })
+ );
+ expect(taskByHashRef.reviewState).toBe('needsFix');
+
+ const listedTasks = parseJsonToolResult(
+ await getTool('task_list').execute({
+ claudeDir,
+ teamName,
+ })
+ );
+ expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
+ 'needsFix'
+ );
+
+ const kanbanCleared = parseJsonToolResult(
+ await getTool('kanban_clear').execute({
+ claudeDir,
+ teamName,
+ taskId: createdTask.id,
+ })
+ );
+ expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined();
+
+ const pid = process.pid;
+
+ const registered = parseJsonToolResult(
+ await getTool('process_register').execute({
+ claudeDir,
+ teamName,
+ pid,
+ label: 'vite',
+ command: 'pnpm dev',
+ from: 'lead',
+ port: 3000,
+ })
+ );
+
+ expect(registered.pid).toBe(pid);
+ expect(registered.label).toBe('vite');
+
+ const listed = parseJsonToolResult(
+ await getTool('process_list').execute({
+ claudeDir,
+ teamName,
+ })
+ );
+
+ expect(listed).toHaveLength(1);
+ expect(listed[0].pid).toBe(pid);
+
+ const stopped = parseJsonToolResult(
+ await getTool('process_stop').execute({
+ claudeDir,
+ teamName,
+ pid,
+ })
+ );
+
+ expect(stopped.pid).toBe(pid);
+ expect(typeof stopped.stoppedAt).toBe('string');
+
+ const unregistered = parseJsonToolResult(
+ await getTool('process_unregister').execute({
+ claudeDir,
+ teamName,
+ pid,
+ })
+ );
+ expect(unregistered).toEqual([]);
+ });
+
+ it('persists full message metadata through message_send', async () => {
+ const claudeDir = makeClaudeDir();
+ const teamName = 'gamma';
+
+ const sent = parseJsonToolResult(
+ await getTool('message_send').execute({
+ claudeDir,
+ teamName,
+ to: 'alice',
+ text: 'Check this',
+ from: 'lead',
+ summary: 'Metadata test',
+ source: 'system_notification',
+ leadSessionId: 'session-42',
+ attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
+ })
+ );
+
+ expect(sent.deliveredToInbox).toBe(true);
+ const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
+ const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(rows[0].source).toBe('system_notification');
+ expect(rows[0].leadSessionId).toBe('session-42');
+ expect(rows[0].attachments[0].filename).toBe('note.txt');
+ });
+
+ it('exposes zod schemas that reject obviously invalid payloads', () => {
+ expect(
+ getTool('task_create').parameters?.safeParse({
+ teamName: 'demo',
+ claudeDir: '/tmp/demo',
+ }).success
+ ).toBe(false);
+
+ expect(
+ getTool('process_register').parameters?.safeParse({
+ teamName: 'demo',
+ pid: 0,
+ label: '',
+ }).success
+ ).toBe(false);
+ });
+
+ it('task_add_comment succeeds even when owner inbox write fails', async () => {
+ const claudeDir = makeClaudeDir();
+ const teamName = 'resilience';
+
+ const task = parseJsonToolResult(
+ await getTool('task_create').execute({
+ claudeDir,
+ teamName,
+ subject: 'Comment resilience test',
+ owner: 'alice',
+ notifyOwner: false,
+ })
+ );
+
+ // Corrupt the inbox file to force notification failure
+ const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes');
+ fs.mkdirSync(inboxDir, { recursive: true });
+ fs.writeFileSync(path.join(inboxDir, 'alice.json'), 'BROKEN JSON');
+
+ const commented = parseJsonToolResult(
+ await getTool('task_add_comment').execute({
+ claudeDir,
+ teamName,
+ taskId: task.id,
+ text: 'Comment should persist despite broken inbox',
+ from: 'bob',
+ })
+ );
+
+ expect(commented.commentId).toBeTruthy();
+
+ // Verify the comment is actually persisted on the task
+ const reloaded = parseJsonToolResult(
+ await getTool('task_get').execute({
+ claudeDir,
+ teamName,
+ taskId: task.id,
+ })
+ );
+
+ expect(reloaded.comments).toHaveLength(1);
+ expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
+ });
+});
diff --git a/mcp-server/tsconfig.test.json b/mcp-server/tsconfig.test.json
new file mode 100644
index 00000000..4f6ab56b
--- /dev/null
+++ b/mcp-server/tsconfig.test.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "noEmit": true,
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/mcp-server/tsup.config.ts b/mcp-server/tsup.config.ts
index 5cd234bb..c1cf301d 100644
--- a/mcp-server/tsup.config.ts
+++ b/mcp-server/tsup.config.ts
@@ -8,7 +8,4 @@ export default defineConfig({
clean: true,
sourcemap: true,
dts: false,
- banner: {
- js: '#!/usr/bin/env node',
- },
});
diff --git a/package.json b/package.json
index 446a4711..d29ac95a 100644
--- a/package.json
+++ b/package.json
@@ -30,11 +30,16 @@
"dist:linux": "electron-builder --linux",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
+ "typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck && pnpm --filter agent-teams-mcp typecheck:test",
"lint": "eslint src/ --cache --cache-location .eslintcache --cache-strategy content",
+ "lint:mcp": "pnpm --filter agent-teams-mcp lint",
"lint:fix": "eslint src/ --fix --cache --cache-location .eslintcache --cache-strategy content",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
- "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
+ "build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
+ "test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
+ "check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e",
+ "check": "pnpm check:workspace && pnpm lint && pnpm lint:mcp",
"fix": "pnpm lint:fix && pnpm format",
"quality": "pnpm check && pnpm format:check && npx knip",
"test:chunks": "tsx test/test-chunk-building.ts",
@@ -95,6 +100,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
@@ -105,10 +111,13 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
+ "agent-teams-controller": "workspace:*",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
+ "croner": "^10.0.1",
+ "cronstrue": "^3.13.0",
"date-fns": "^3.6.0",
"diff": "^8.0.3",
"dompurify": "^3.3.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3d4fa02b..0bd4c861 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -113,6 +113,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-hover-card':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -143,6 +146,9 @@ importers:
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
+ agent-teams-controller:
+ specifier: workspace:*
+ version: link:agent-teams-controller
chokidar:
specifier: ^4.0.3
version: 4.0.3
@@ -155,6 +161,12 @@ importers:
cmdk:
specifier: 1.0.4
version: 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ croner:
+ specifier: ^10.0.1
+ version: 10.0.1
+ cronstrue:
+ specifier: ^3.13.0
+ version: 3.13.0
date-fns:
specifier: ^3.6.0
version: 3.6.0
@@ -373,8 +385,13 @@ importers:
specifier: ^3.1.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0)
+ agent-teams-controller: {}
+
mcp-server:
dependencies:
+ agent-teams-controller:
+ specifier: workspace:*
+ version: link:../agent-teams-controller
fastmcp:
specifier: ^3.34.0
version: 3.34.0
@@ -1538,6 +1555,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-hover-card@1.1.15':
+ resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@@ -3047,6 +3077,14 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+ croner@10.0.1:
+ resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
+ engines: {node: '>=18.0'}
+
+ cronstrue@3.13.0:
+ resolution: {integrity: sha512-M06cKwRIN46AyuM8BOmF1HUkBTkd3/h7uYImnrH1T3wtRKBGOibVo3jZ42VheEvx8LtgZbG/4GI35vfIxYxMug==}
+ hasBin: true
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -8061,6 +8099,23 @@ snapshots:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
+ '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.27
+ '@types/react-dom': 18.3.7(@types/react@18.3.27)
+
'@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
@@ -9754,6 +9809,10 @@ snapshots:
crelt@1.0.6: {}
+ croner@10.0.1: {}
+
+ cronstrue@3.13.0: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0caafff1..5f06694a 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,5 @@
packages:
+ - agent-teams-controller
- mcp-server
ignoredBuiltDependencies:
- esbuild
diff --git a/src/main/index.ts b/src/main/index.ts
index 3a2df714..79e64197 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -16,12 +16,19 @@
// 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';
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
+import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
+import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
+import { SchedulerService } from '@main/services/schedule/SchedulerService';
import {
CONTEXT_CHANGED,
+ SCHEDULE_CHANGE,
SSH_STATUS,
TEAM_CHANGE,
TEAM_TOOL_APPROVAL_EVENT,
@@ -60,12 +67,23 @@ import {
ServiceContextRegistry,
SshConnectionManager,
TaskBoundaryParser,
- TeamAgentToolsInstaller,
TeamDataService,
TeamMemberLogsFinder,
TeamProvisioningService,
UpdaterService,
} from './services';
+import {
+ ApiKeyService,
+ ExtensionFacadeService,
+ GlamaMcpEnrichmentService,
+ McpCatalogAggregator,
+ McpInstallationStateService,
+ McpInstallService,
+ OfficialMcpRegistryService,
+ PluginCatalogService,
+ PluginInstallationStateService,
+ PluginInstallService,
+} from './services/extensions';
import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types';
@@ -267,6 +285,7 @@ async function notifyNewSentMessages(teamName: string): Promise {
for (let i = 0; i < newMessages.length; i++) {
const msg = newMessages[i];
+ if ((msg.to ?? '').trim() !== 'user') continue;
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
// Skip internal coordination noise
@@ -318,6 +337,7 @@ let teamProvisioningService: TeamProvisioningService;
let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer;
+let schedulerService: SchedulerService;
// File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null;
@@ -431,17 +451,31 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') {
- // Auto-relay ONLY lead-inbox changes into the live lead process.
- // (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
+ if (teamDataService) {
+ void teamDataService
+ .reconcileTeamArtifacts(teamName)
+ .catch((e: unknown) =>
+ logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
+ );
+ }
+
+ // Relay inbox changes into active runtime recipients.
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
const match = /^inboxes\/(.+)\.json$/.exec(detail);
if (match && teamDataService) {
const inboxName = match[1];
+
+ // Mark member as online when their first inbox message arrives (spawn tracking).
+ teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName);
+
void teamDataService
.getLeadMemberName(teamName)
.then((leadName) => {
- if (!leadName || inboxName !== leadName) return;
- return teamProvisioningService.relayLeadInboxMessages(teamName);
+ if (!leadName) return;
+ if (inboxName === leadName) {
+ return teamProvisioningService.relayLeadInboxMessages(teamName);
+ }
+ return teamProvisioningService.relayMemberInboxMessages(teamName, inboxName);
})
.catch((e: unknown) =>
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)
@@ -480,6 +514,12 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Task change events: notify lead when teammate starts a task via CLI ---
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
+ void teamDataService
+ .reconcileTeamArtifacts(teamName)
+ .catch((e: unknown) =>
+ logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
+ );
+
const taskId = detail.replace('.json', '');
void teamDataService
.notifyLeadOnTeammateTaskStart(teamName, taskId)
@@ -631,6 +671,18 @@ 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
+ );
+ teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
+
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
const taskBoundaryParser = new TaskBoundaryParser();
@@ -639,6 +691,37 @@ function initializeServices(): void {
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
const reviewApplier = new ReviewApplierService();
+ // Create SchedulerService for cron-based task execution
+ const scheduleRepository = new JsonScheduleRepository();
+ const scheduledTaskExecutor = new ScheduledTaskExecutor();
+ schedulerService = new SchedulerService(
+ scheduleRepository,
+ scheduledTaskExecutor,
+ async (cwd: string) => {
+ const result = await teamProvisioningService.prepareForProvisioning(cwd, {
+ forceFresh: true,
+ });
+ return { ready: result.ready, message: result.message };
+ }
+ );
+ // Extension Store services
+ const pluginCatalogService = new PluginCatalogService();
+ const pluginStateService = new PluginInstallationStateService();
+ const officialMcpRegistry = new OfficialMcpRegistryService();
+ const glamaMcpService = new GlamaMcpEnrichmentService();
+ const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
+ const mcpStateService = new McpInstallationStateService();
+ const extensionFacadeService = new ExtensionFacadeService(
+ pluginCatalogService,
+ pluginStateService,
+ mcpAggregator,
+ mcpStateService
+ );
+
+ // Install services — pass null for binary (uses PATH lookup via execCli fallback)
+ const pluginInstallService = new PluginInstallService(null, pluginCatalogService);
+ const mcpInstallService = new McpInstallService(null, mcpAggregator);
+ const apiKeyService = new ApiKeyService();
// warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer();
@@ -652,6 +735,13 @@ function initializeServices(): void {
};
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
+ // Allow SchedulerService to push schedule events to renderer
+ schedulerService.setChangeEmitter((event) => {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(SCHEDULE_CHANGE, event);
+ }
+ });
+
teamProvisioningService.setToolApprovalEventEmitter((event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event);
@@ -675,6 +765,7 @@ function initializeServices(): void {
full: onContextSwitched,
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
reconfigureLocalContextForClaudeRoot();
+ void schedulerService?.reloadForClaudeRootChange();
},
},
{
@@ -686,7 +777,13 @@ function initializeServices(): void {
reviewApplier,
gitDiffFallback,
cliInstallerService,
- ptyTerminalService
+ ptyTerminalService,
+ schedulerService,
+ extensionFacadeService,
+ pluginInstallService,
+ mcpInstallService,
+ apiKeyService,
+ crossTeamService
);
// Forward SSH state changes to renderer and HTTP SSE clients
@@ -795,6 +892,11 @@ function shutdownServices(): void {
teamDataService.stopProcessHealthPolling();
}
+ // Stop scheduled task execution and croner jobs
+ if (schedulerService) {
+ void schedulerService.stop();
+ }
+
// Kill all PTY processes
if (ptyTerminalService) {
ptyTerminalService.killAll();
@@ -941,8 +1043,8 @@ function createWindow(): void {
// The window is now visible and responsive; these run in the background.
setTimeout(() => {
void teamProvisioningService.warmup();
- void new TeamAgentToolsInstaller().ensureInstalled();
teamDataService.startProcessHealthPolling();
+ void schedulerService?.start();
}, 5000);
}
});
diff --git a/src/main/ipc/crossTeam.ts b/src/main/ipc/crossTeam.ts
new file mode 100644
index 00000000..03d57a25
--- /dev/null
+++ b/src/main/ipc/crossTeam.ts
@@ -0,0 +1,101 @@
+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 { isAgentActionMode } from '../services/team/actionModeInstructions';
+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(
+ operation: string,
+ handler: () => Promise
+): Promise> {
+ 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> {
+ return wrapCrossTeamHandler('send', () => {
+ if (!request || typeof request !== 'object') {
+ throw new Error('Invalid request');
+ }
+ const req = request as Record;
+ if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) {
+ throw new Error('actionMode must be one of: do, ask, delegate');
+ }
+ return getService().send({
+ fromTeam: String(req.fromTeam ?? ''),
+ fromMember: String(req.fromMember ?? ''),
+ toTeam: String(req.toTeam ?? ''),
+ conversationId: typeof req.conversationId === 'string' ? req.conversationId : undefined,
+ replyToConversationId:
+ typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined,
+ text: String(req.text ?? ''),
+ actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined,
+ summary: typeof req.summary === 'string' ? req.summary : undefined,
+ chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
+ });
+ });
+}
+
+async function handleListTargets(
+ _event: IpcMainInvokeEvent,
+ excludeTeam?: string
+): Promise> {
+ return wrapCrossTeamHandler('listTargets', () =>
+ getService().listAvailableTargets(typeof excludeTeam === 'string' ? excludeTeam : undefined)
+ );
+}
+
+async function handleGetOutbox(
+ _event: IpcMainInvokeEvent,
+ teamName: string
+): Promise> {
+ 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);
+}
diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts
new file mode 100644
index 00000000..53411a2b
--- /dev/null
+++ b/src/main/ipc/extensions.ts
@@ -0,0 +1,411 @@
+/**
+ * IPC handlers for Extension Store (plugin catalog + MCP registry).
+ *
+ * Phase 2: read-only plugin catalog (getAll, getReadme).
+ * Phase 3: read-only MCP registry (search, browse, getById, getInstalled).
+ * Phase 5: install/uninstall mutations.
+ */
+
+import { createLogger } from '@shared/utils/logger';
+import type {
+ ApiKeyEntry,
+ ApiKeyLookupResult,
+ ApiKeySaveRequest,
+ ApiKeyStorageStatus,
+ EnrichedPlugin,
+ InstalledMcpEntry,
+ McpCatalogItem,
+ McpCustomInstallRequest,
+ McpInstallRequest,
+ McpSearchResult,
+ OperationResult,
+ PluginInstallRequest,
+} from '@shared/types/extensions';
+import type { IpcMain, IpcMainInvokeEvent } from 'electron';
+
+import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
+import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
+import type { McpInstallService } from '../services/extensions/install/McpInstallService';
+import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
+
+import {
+ API_KEYS_DELETE,
+ API_KEYS_LIST,
+ API_KEYS_LOOKUP,
+ API_KEYS_SAVE,
+ API_KEYS_STORAGE_STATUS,
+ MCP_GITHUB_STARS,
+ MCP_REGISTRY_BROWSE,
+ MCP_REGISTRY_GET_BY_ID,
+ MCP_REGISTRY_GET_INSTALLED,
+ MCP_REGISTRY_INSTALL,
+ MCP_REGISTRY_INSTALL_CUSTOM,
+ MCP_REGISTRY_SEARCH,
+ MCP_REGISTRY_UNINSTALL,
+ PLUGIN_GET_ALL,
+ PLUGIN_GET_README,
+ PLUGIN_INSTALL,
+ PLUGIN_UNINSTALL,
+} from '@preload/constants/ipcChannels';
+
+import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
+
+const logger = createLogger('IPC:extensions');
+
+/** Allowed scope values */
+const VALID_SCOPES = new Set(['local', 'user', 'project']);
+
+// ── Module state ───────────────────────────────────────────────────────────
+
+const starsService = new GitHubStarsService();
+
+let extensionFacade: ExtensionFacadeService | null = null;
+let pluginInstaller: PluginInstallService | null = null;
+let mcpInstaller: McpInstallService | null = null;
+let apiKeyService: ApiKeyService | null = null;
+
+// ── Lifecycle ──────────────────────────────────────────────────────────────
+
+export function initializeExtensionHandlers(
+ facade: ExtensionFacadeService,
+ pluginInstall?: PluginInstallService,
+ mcpInstall?: McpInstallService,
+ apiKeys?: ApiKeyService
+): void {
+ extensionFacade = facade;
+ pluginInstaller = pluginInstall ?? null;
+ mcpInstaller = mcpInstall ?? null;
+ apiKeyService = apiKeys ?? null;
+}
+
+export function registerExtensionHandlers(ipcMain: IpcMain): void {
+ ipcMain.handle(PLUGIN_GET_ALL, handleGetAll);
+ ipcMain.handle(PLUGIN_GET_README, handleGetReadme);
+ ipcMain.handle(PLUGIN_INSTALL, handlePluginInstall);
+ ipcMain.handle(PLUGIN_UNINSTALL, handlePluginUninstall);
+ ipcMain.handle(MCP_REGISTRY_SEARCH, handleMcpSearch);
+ ipcMain.handle(MCP_REGISTRY_BROWSE, handleMcpBrowse);
+ ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
+ ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
+ ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
+ ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom);
+ ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall);
+ ipcMain.handle(API_KEYS_LIST, handleApiKeysList);
+ ipcMain.handle(API_KEYS_SAVE, handleApiKeysSave);
+ ipcMain.handle(API_KEYS_DELETE, handleApiKeysDelete);
+ ipcMain.handle(API_KEYS_LOOKUP, handleApiKeysLookup);
+ ipcMain.handle(API_KEYS_STORAGE_STATUS, handleApiKeysStorageStatus);
+ ipcMain.handle(MCP_GITHUB_STARS, handleMcpGitHubStars);
+}
+
+export function removeExtensionHandlers(ipcMain: IpcMain): void {
+ ipcMain.removeHandler(PLUGIN_GET_ALL);
+ ipcMain.removeHandler(PLUGIN_GET_README);
+ ipcMain.removeHandler(PLUGIN_INSTALL);
+ ipcMain.removeHandler(PLUGIN_UNINSTALL);
+ ipcMain.removeHandler(MCP_REGISTRY_SEARCH);
+ ipcMain.removeHandler(MCP_REGISTRY_BROWSE);
+ ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
+ ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
+ ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
+ ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM);
+ ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL);
+ ipcMain.removeHandler(API_KEYS_LIST);
+ ipcMain.removeHandler(API_KEYS_SAVE);
+ ipcMain.removeHandler(API_KEYS_DELETE);
+ ipcMain.removeHandler(API_KEYS_LOOKUP);
+ ipcMain.removeHandler(API_KEYS_STORAGE_STATUS);
+ ipcMain.removeHandler(MCP_GITHUB_STARS);
+}
+
+// ── Service guard ──────────────────────────────────────────────────────────
+
+function getFacade(): ExtensionFacadeService {
+ if (!extensionFacade) {
+ throw new Error('Extension handlers are not initialized');
+ }
+ return extensionFacade;
+}
+
+// ── Error wrapper ──────────────────────────────────────────────────────────
+
+interface IpcResult {
+ success: boolean;
+ data?: T;
+ error?: string;
+}
+
+async function wrapHandler(operation: string, handler: () => Promise): Promise> {
+ try {
+ const data = await handler();
+ return { success: true, data };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger.error(`[extensions:${operation}] ${message}`);
+ return { success: false, error: message };
+ }
+}
+
+// ── Plugin Handlers ────────────────────────────────────────────────────────
+
+async function handleGetAll(
+ _event: IpcMainInvokeEvent,
+ projectPath?: string,
+ forceRefresh?: boolean
+): Promise> {
+ return wrapHandler('getAll', () =>
+ getFacade().getEnrichedPlugins(
+ typeof projectPath === 'string' ? projectPath : undefined,
+ typeof forceRefresh === 'boolean' ? forceRefresh : false
+ )
+ );
+}
+
+async function handleGetReadme(
+ _event: IpcMainInvokeEvent,
+ pluginId?: string
+): Promise> {
+ return wrapHandler('getReadme', () => {
+ if (typeof pluginId !== 'string' || !pluginId) {
+ throw new Error('pluginId is required');
+ }
+ return getFacade().getPluginReadme(pluginId);
+ });
+}
+
+// ── MCP Handlers ───────────────────────────────────────────────────────────
+
+async function handleMcpSearch(
+ _event: IpcMainInvokeEvent,
+ query?: string,
+ limit?: number
+): Promise> {
+ return wrapHandler('mcpSearch', () =>
+ getFacade().searchMcp(
+ typeof query === 'string' ? query : '',
+ typeof limit === 'number' ? limit : undefined
+ )
+ );
+}
+
+async function handleMcpBrowse(
+ _event: IpcMainInvokeEvent,
+ cursor?: string,
+ limit?: number
+): Promise> {
+ return wrapHandler('mcpBrowse', () =>
+ getFacade().browseMcp(
+ typeof cursor === 'string' ? cursor : undefined,
+ typeof limit === 'number' ? limit : undefined
+ )
+ );
+}
+
+async function handleMcpGetById(
+ _event: IpcMainInvokeEvent,
+ registryId?: string
+): Promise> {
+ return wrapHandler('mcpGetById', () => {
+ if (typeof registryId !== 'string' || !registryId) {
+ throw new Error('registryId is required');
+ }
+ return getFacade().getMcpById(registryId);
+ });
+}
+
+async function handleMcpGetInstalled(
+ _event: IpcMainInvokeEvent,
+ projectPath?: string
+): Promise> {
+ return wrapHandler('mcpGetInstalled', () =>
+ getFacade().getInstalledMcp(typeof projectPath === 'string' ? projectPath : undefined)
+ );
+}
+
+// ── Install/Uninstall Handlers ────────────────────────────────────────────
+
+function getPluginInstaller(): PluginInstallService {
+ if (!pluginInstaller) {
+ throw new Error('Plugin installer not initialized');
+ }
+ return pluginInstaller;
+}
+
+function getMcpInstaller(): McpInstallService {
+ if (!mcpInstaller) {
+ throw new Error('MCP installer not initialized');
+ }
+ return mcpInstaller;
+}
+
+async function handlePluginInstall(
+ _event: IpcMainInvokeEvent,
+ request?: PluginInstallRequest
+): Promise> {
+ return wrapHandler('pluginInstall', async () => {
+ if (!request || typeof request.pluginId !== 'string' || !request.pluginId) {
+ throw new Error('Invalid install request: pluginId is required');
+ }
+ if (request.scope && !VALID_SCOPES.has(request.scope)) {
+ throw new Error(`Invalid scope: "${request.scope}"`);
+ }
+ const result = await getPluginInstaller().install(request);
+ if (result.state === 'success') {
+ getFacade().invalidateInstalledCache();
+ }
+ return result;
+ });
+}
+
+async function handlePluginUninstall(
+ _event: IpcMainInvokeEvent,
+ pluginId?: string,
+ scope?: string,
+ projectPath?: string
+): Promise> {
+ return wrapHandler('pluginUninstall', async () => {
+ if (typeof pluginId !== 'string' || !pluginId) {
+ throw new Error('pluginId is required');
+ }
+ if (scope && !VALID_SCOPES.has(scope)) {
+ throw new Error(`Invalid scope: "${scope}"`);
+ }
+ const result = await getPluginInstaller().uninstall(
+ pluginId,
+ typeof scope === 'string' ? scope : undefined,
+ typeof projectPath === 'string' ? projectPath : undefined
+ );
+ if (result.state === 'success') {
+ getFacade().invalidateInstalledCache();
+ }
+ return result;
+ });
+}
+
+async function handleMcpInstall(
+ _event: IpcMainInvokeEvent,
+ request?: McpInstallRequest
+): Promise> {
+ return wrapHandler('mcpInstall', async () => {
+ if (!request || typeof request.registryId !== 'string' || !request.registryId) {
+ throw new Error('Invalid install request: registryId is required');
+ }
+ if (typeof request.serverName !== 'string' || !request.serverName) {
+ throw new Error('Invalid install request: serverName is required');
+ }
+ if (request.scope && !VALID_SCOPES.has(request.scope)) {
+ throw new Error(`Invalid scope: "${request.scope}"`);
+ }
+ const result = await getMcpInstaller().install(request);
+ if (result.state === 'success') {
+ getFacade().invalidateInstalledCache();
+ }
+ return result;
+ });
+}
+
+async function handleMcpInstallCustom(
+ _event: IpcMainInvokeEvent,
+ request?: McpCustomInstallRequest
+): Promise> {
+ return wrapHandler('mcpInstallCustom', async () => {
+ if (!request || typeof request.serverName !== 'string' || !request.serverName) {
+ throw new Error('Invalid custom install request: serverName is required');
+ }
+ if (!request.installSpec) {
+ throw new Error('Invalid custom install request: installSpec is required');
+ }
+ if (request.scope && !VALID_SCOPES.has(request.scope)) {
+ throw new Error(`Invalid scope: "${request.scope}"`);
+ }
+ const result = await getMcpInstaller().installCustom(request);
+ if (result.state === 'success') {
+ getFacade().invalidateInstalledCache();
+ }
+ return result;
+ });
+}
+
+async function handleMcpUninstall(
+ _event: IpcMainInvokeEvent,
+ name?: string,
+ scope?: string,
+ projectPath?: string
+): Promise> {
+ return wrapHandler('mcpUninstall', async () => {
+ if (typeof name !== 'string' || !name) {
+ throw new Error('Server name is required');
+ }
+ if (scope && !VALID_SCOPES.has(scope)) {
+ throw new Error(`Invalid scope: "${scope}"`);
+ }
+ const result = await getMcpInstaller().uninstall(
+ name,
+ typeof scope === 'string' ? scope : undefined,
+ typeof projectPath === 'string' ? projectPath : undefined
+ );
+ if (result.state === 'success') {
+ getFacade().invalidateInstalledCache();
+ }
+ return result;
+ });
+}
+
+// ── API Keys Guard ─────────────────────────────────────────────────────────
+
+function getApiKeyService(): ApiKeyService {
+ if (!apiKeyService) throw new Error('API key service not initialized');
+ return apiKeyService;
+}
+
+// ── API Keys Handlers ──────────────────────────────────────────────────────
+
+async function handleApiKeysList(): Promise> {
+ return wrapHandler('apiKeysList', () => getApiKeyService().list());
+}
+
+async function handleApiKeysSave(
+ _event: IpcMainInvokeEvent,
+ request?: ApiKeySaveRequest
+): Promise> {
+ return wrapHandler('apiKeysSave', () => {
+ if (!request) throw new Error('Request is required');
+ return getApiKeyService().save(request);
+ });
+}
+
+async function handleApiKeysDelete(
+ _event: IpcMainInvokeEvent,
+ id?: string
+): Promise> {
+ return wrapHandler('apiKeysDelete', () => {
+ if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
+ return getApiKeyService().delete(id);
+ });
+}
+
+async function handleApiKeysLookup(
+ _event: IpcMainInvokeEvent,
+ envVarNames?: string[]
+): Promise> {
+ return wrapHandler('apiKeysLookup', () => {
+ if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required');
+ return getApiKeyService().lookup(envVarNames);
+ });
+}
+
+async function handleApiKeysStorageStatus(): Promise> {
+ return wrapHandler('apiKeysStorageStatus', () => getApiKeyService().getStorageStatus());
+}
+
+// ── GitHub Stars Handler ──────────────────────────────────────────────────
+
+async function handleMcpGitHubStars(
+ _event: IpcMainInvokeEvent,
+ repositoryUrls?: string[]
+): Promise>> {
+ return wrapHandler('mcpGitHubStars', () => {
+ if (!Array.isArray(repositoryUrls)) throw new Error('repositoryUrls array is required');
+ return starsService.fetchStars(repositoryUrls);
+ });
+}
diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts
index fc716724..f9d7d6cc 100644
--- a/src/main/ipc/guards.ts
+++ b/src/main/ipc/guards.ts
@@ -13,7 +13,7 @@ const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
-const TASK_ID_PATTERN = /^\d{1,10}$/;
+const TASK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/;
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
@@ -130,13 +130,13 @@ export function validateTeamName(teamName: unknown): ValidationResult {
}
export function validateTaskId(taskId: unknown): ValidationResult {
- const basic = validateString(taskId, 'taskId', 16);
+ const basic = validateString(taskId, 'taskId', 64);
if (!basic.valid) {
return basic;
}
if (!TASK_ID_PATTERN.test(basic.value!)) {
- return { valid: false, error: 'taskId must contain only digits' };
+ return { valid: false, error: 'taskId contains invalid characters' };
}
return { valid: true, value: basic.value };
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts
index c17fee2a..0a733e85 100644
--- a/src/main/ipc/handlers.ts
+++ b/src/main/ipc/handlers.ts
@@ -28,7 +28,17 @@ import {
registerContextHandlers,
removeContextHandlers,
} from './context';
+import {
+ initializeCrossTeamHandlers,
+ registerCrossTeamHandlers,
+ removeCrossTeamHandlers,
+} from './crossTeam';
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
+import {
+ initializeExtensionHandlers,
+ registerExtensionHandlers,
+ removeExtensionHandlers,
+} from './extensions';
import {
initializeHttpServerHandlers,
registerHttpServerHandlers,
@@ -44,6 +54,11 @@ import {
} from './projects';
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';
+import {
+ initializeScheduleHandlers,
+ registerScheduleHandlers,
+ removeScheduleHandlers,
+} from './schedule';
import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search';
import {
initializeSessionHandlers,
@@ -88,6 +103,12 @@ 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';
+import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
+import type { SchedulerService } from '../services/schedule/SchedulerService';
/**
* Initializes IPC handlers with service registry.
@@ -114,7 +135,13 @@ export function initializeIpcHandlers(
reviewApplier?: ReviewApplierService,
gitDiffFallback?: GitDiffFallback,
cliInstaller?: CliInstallerService,
- ptyTerminal?: PtyTerminalService
+ ptyTerminal?: PtyTerminalService,
+ schedulerService?: SchedulerService,
+ extensionFacade?: ExtensionFacadeService,
+ pluginInstaller?: PluginInstallService,
+ mcpInstaller?: McpInstallService,
+ apiKeyService?: ApiKeyService,
+ crossTeamService?: CrossTeamService
): void {
// Initialize domain handlers with registry
initializeProjectHandlers(registry);
@@ -146,6 +173,15 @@ export function initializeIpcHandlers(
initializeTerminalHandlers(ptyTerminal);
}
initializeEditorHandlers();
+ if (schedulerService) {
+ initializeScheduleHandlers(schedulerService);
+ }
+ if (extensionFacade) {
+ initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService);
+ }
+ if (crossTeamService) {
+ initializeCrossTeamHandlers(crossTeamService);
+ }
if (changeExtractor) {
initializeReviewHandlers({
@@ -173,6 +209,7 @@ export function initializeIpcHandlers(
registerEditorHandlers(ipcMain);
registerWindowHandlers(ipcMain);
registerRendererLogHandlers(ipcMain);
+ registerScheduleHandlers(ipcMain);
if (cliInstaller) {
registerCliInstallerHandlers(ipcMain);
}
@@ -182,6 +219,12 @@ export function initializeIpcHandlers(
if (httpServerDeps) {
registerHttpServerHandlers(ipcMain);
}
+ if (extensionFacade) {
+ registerExtensionHandlers(ipcMain);
+ }
+ if (crossTeamService) {
+ registerCrossTeamHandlers(ipcMain);
+ }
logger.info('All handlers registered');
}
@@ -207,9 +250,12 @@ export function removeIpcHandlers(): void {
removeEditorHandlers(ipcMain);
removeWindowHandlers(ipcMain);
removeRendererLogHandlers(ipcMain);
+ removeScheduleHandlers(ipcMain);
removeCliInstallerHandlers(ipcMain);
removeTerminalHandlers(ipcMain);
removeHttpServerHandlers(ipcMain);
+ removeExtensionHandlers(ipcMain);
+ removeCrossTeamHandlers(ipcMain);
logger.info('All handlers removed');
}
diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts
index 6efb399c..eb33ca92 100644
--- a/src/main/ipc/review.ts
+++ b/src/main/ipc/review.ts
@@ -174,6 +174,7 @@ async function handleGetTaskChanges(
typeof (i as Record).completedAt === 'string')
) as { startedAt: string; completedAt?: string }[])
: undefined,
+ summaryOnly: (options as Record).summaryOnly === true,
}
: undefined;
diff --git a/src/main/ipc/schedule.ts b/src/main/ipc/schedule.ts
new file mode 100644
index 00000000..95c55322
--- /dev/null
+++ b/src/main/ipc/schedule.ts
@@ -0,0 +1,204 @@
+/**
+ * IPC handlers for scheduled tasks.
+ *
+ * Pattern: initializeScheduleHandlers(service) → registerScheduleHandlers(ipcMain)
+ * → removeScheduleHandlers(ipcMain)
+ */
+
+import {
+ SCHEDULE_CREATE,
+ SCHEDULE_DELETE,
+ SCHEDULE_GET,
+ SCHEDULE_GET_RUN_LOGS,
+ SCHEDULE_GET_RUNS,
+ SCHEDULE_LIST,
+ SCHEDULE_PAUSE,
+ SCHEDULE_RESUME,
+ SCHEDULE_TRIGGER_NOW,
+ SCHEDULE_UPDATE,
+ // 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 { SchedulerService } from '../services/schedule/SchedulerService';
+import type {
+ CreateScheduleInput,
+ IpcResult,
+ Schedule,
+ ScheduleRun,
+ UpdateSchedulePatch,
+} from '@shared/types';
+import type { IpcMain, IpcMainInvokeEvent } from 'electron';
+
+const logger = createLogger('IPC:schedule');
+
+let schedulerService: SchedulerService | null = null;
+
+function getService(): SchedulerService {
+ if (!schedulerService) {
+ throw new Error('SchedulerService not initialized');
+ }
+ return schedulerService;
+}
+
+async function wrapScheduleHandler(
+ operation: string,
+ handler: () => Promise
+): Promise> {
+ try {
+ const data = await handler();
+ return { success: true, data };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger.error(`[schedule:${operation}] ${message}`);
+ return { success: false, error: message };
+ }
+}
+
+// =============================================================================
+// Handlers
+// =============================================================================
+
+async function handleList(_event: IpcMainInvokeEvent): Promise> {
+ return wrapScheduleHandler('list', () => getService().listSchedules());
+}
+
+async function handleGet(
+ _event: IpcMainInvokeEvent,
+ id: unknown
+): Promise> {
+ if (typeof id !== 'string' || !id.trim()) {
+ return { success: false, error: 'id must be a non-empty string' };
+ }
+ return wrapScheduleHandler('get', () => getService().getSchedule(id));
+}
+
+async function handleCreate(
+ _event: IpcMainInvokeEvent,
+ input: unknown
+): Promise> {
+ if (!input || typeof input !== 'object') {
+ return { success: false, error: 'input must be an object' };
+ }
+ const inp = input as CreateScheduleInput;
+ if (!inp.teamName || !inp.cronExpression || !inp.timezone || !inp.launchConfig) {
+ return {
+ success: false,
+ error: 'Missing required fields: teamName, cronExpression, timezone, launchConfig',
+ };
+ }
+ if (!inp.launchConfig.cwd || !inp.launchConfig.prompt) {
+ return { success: false, error: 'launchConfig requires cwd and prompt' };
+ }
+ return wrapScheduleHandler('create', () => getService().createSchedule(inp));
+}
+
+async function handleUpdate(
+ _event: IpcMainInvokeEvent,
+ id: unknown,
+ patch: unknown
+): Promise> {
+ if (typeof id !== 'string' || !id.trim()) {
+ return { success: false, error: 'id must be a non-empty string' };
+ }
+ if (!patch || typeof patch !== 'object') {
+ return { success: false, error: 'patch must be an object' };
+ }
+ return wrapScheduleHandler('update', () =>
+ getService().updateSchedule(id, patch as UpdateSchedulePatch)
+ );
+}
+
+async function handleDelete(_event: IpcMainInvokeEvent, id: unknown): Promise> {
+ if (typeof id !== 'string' || !id.trim()) {
+ return { success: false, error: 'id must be a non-empty string' };
+ }
+ return wrapScheduleHandler('delete', () => getService().deleteSchedule(id));
+}
+
+async function handlePause(_event: IpcMainInvokeEvent, id: unknown): Promise> {
+ if (typeof id !== 'string' || !id.trim()) {
+ return { success: false, error: 'id must be a non-empty string' };
+ }
+ return wrapScheduleHandler('pause', () => getService().pauseSchedule(id));
+}
+
+async function handleResume(_event: IpcMainInvokeEvent, id: unknown): Promise> {
+ if (typeof id !== 'string' || !id.trim()) {
+ return { success: false, error: 'id must be a non-empty string' };
+ }
+ return wrapScheduleHandler('resume', () => getService().resumeSchedule(id));
+}
+
+async function handleTriggerNow(
+ _event: IpcMainInvokeEvent,
+ id: unknown
+): Promise> {
+ if (typeof id !== 'string' || !id.trim()) {
+ return { success: false, error: 'id must be a non-empty string' };
+ }
+ return wrapScheduleHandler('triggerNow', () => getService().triggerNow(id));
+}
+
+async function handleGetRuns(
+ _event: IpcMainInvokeEvent,
+ scheduleId: unknown,
+ opts?: unknown
+): Promise> {
+ if (typeof scheduleId !== 'string' || !scheduleId.trim()) {
+ return { success: false, error: 'scheduleId must be a non-empty string' };
+ }
+ const parsedOpts =
+ opts && typeof opts === 'object' ? (opts as { limit?: number; offset?: number }) : undefined;
+ return wrapScheduleHandler('getRuns', () => getService().getRuns(scheduleId, parsedOpts));
+}
+
+async function handleGetRunLogs(
+ _event: IpcMainInvokeEvent,
+ scheduleId: unknown,
+ runId: unknown
+): Promise> {
+ if (typeof scheduleId !== 'string' || !scheduleId.trim()) {
+ return { success: false, error: 'scheduleId must be a non-empty string' };
+ }
+ if (typeof runId !== 'string' || !runId.trim()) {
+ return { success: false, error: 'runId must be a non-empty string' };
+ }
+ return wrapScheduleHandler('getRunLogs', () => getService().getRunLogs(scheduleId, runId));
+}
+
+// =============================================================================
+// Lifecycle
+// =============================================================================
+
+export function initializeScheduleHandlers(service: SchedulerService): void {
+ schedulerService = service;
+}
+
+export function registerScheduleHandlers(ipcMain: IpcMain): void {
+ ipcMain.handle(SCHEDULE_LIST, handleList);
+ ipcMain.handle(SCHEDULE_GET, handleGet);
+ ipcMain.handle(SCHEDULE_CREATE, handleCreate);
+ ipcMain.handle(SCHEDULE_UPDATE, handleUpdate);
+ ipcMain.handle(SCHEDULE_DELETE, handleDelete);
+ ipcMain.handle(SCHEDULE_PAUSE, handlePause);
+ ipcMain.handle(SCHEDULE_RESUME, handleResume);
+ ipcMain.handle(SCHEDULE_TRIGGER_NOW, handleTriggerNow);
+ ipcMain.handle(SCHEDULE_GET_RUNS, handleGetRuns);
+ ipcMain.handle(SCHEDULE_GET_RUN_LOGS, handleGetRunLogs);
+ logger.info('Schedule handlers registered');
+}
+
+export function removeScheduleHandlers(ipcMain: IpcMain): void {
+ ipcMain.removeHandler(SCHEDULE_LIST);
+ ipcMain.removeHandler(SCHEDULE_GET);
+ ipcMain.removeHandler(SCHEDULE_CREATE);
+ ipcMain.removeHandler(SCHEDULE_UPDATE);
+ ipcMain.removeHandler(SCHEDULE_DELETE);
+ ipcMain.removeHandler(SCHEDULE_PAUSE);
+ ipcMain.removeHandler(SCHEDULE_RESUME);
+ ipcMain.removeHandler(SCHEDULE_TRIGGER_NOW);
+ ipcMain.removeHandler(SCHEDULE_GET_RUNS);
+ ipcMain.removeHandler(SCHEDULE_GET_RUN_LOGS);
+ logger.info('Schedule handlers removed');
+}
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index 3d5aa7c0..0098cae6 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -26,6 +26,7 @@ import {
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LEAD_CONTEXT,
+ TEAM_MEMBER_SPAWN_STATUSES,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
@@ -47,6 +48,7 @@ import {
TEAM_START_TASK,
TEAM_STOP,
TEAM_TOOL_APPROVAL_RESPOND,
+ TEAM_TOOL_APPROVAL_SETTINGS,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
@@ -54,11 +56,17 @@ import {
TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
+ TEAM_VALIDATE_CLI_ARGS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
+import {
+ extractFlagsFromHelp,
+ extractUserFlags,
+ PROTECTED_CLI_FLAGS,
+} from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
@@ -67,6 +75,10 @@ import * as path from 'path';
import { ConfigManager } from '../services/infrastructure/ConfigManager';
import { NotificationManager } from '../services/infrastructure/NotificationManager';
+import {
+ buildActionModeAgentBlock,
+ isAgentActionMode,
+} from '../services/team/actionModeInstructions';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
@@ -86,6 +98,7 @@ import type {
TeamProvisioningService,
} from '../services';
import type {
+ AgentActionMode,
AttachmentFileData,
AttachmentMeta,
AttachmentPayload,
@@ -97,6 +110,7 @@ import type {
LeadContextUsage,
MemberFullStats,
MemberLogSummary,
+ MemberSpawnStatusEntry,
SendMessageRequest,
SendMessageResult,
TaskAttachmentMeta,
@@ -117,8 +131,10 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
+ ToolApprovalSettings,
UpdateKanbanPatch,
} from '@shared/types';
+import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
const logger = createLogger('IPC:teams');
@@ -239,6 +255,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
+ ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
@@ -250,6 +267,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
+ ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs);
+ ipcMain.handle(TEAM_TOOL_APPROVAL_SETTINGS, handleToolApprovalSettings);
logger.info('Team handlers registered');
}
@@ -294,6 +313,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_KILL_PROCESS);
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
+ ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
@@ -305,6 +325,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
+ ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
+ ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS);
}
function getTeamDataService(): TeamDataService {
@@ -407,13 +429,21 @@ async function handleGetData(
}
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
+ const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
+ !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
+ const getLeadThoughtFingerprint = (msg: {
+ from: string;
+ text: string;
+ leadSessionId?: string;
+ }): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
- // Collect text fingerprints from ALL non-live messages (inbox, lead_session, sentMessages)
- // so we can dedup lead_process live messages against them.
+ // Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
+ // repeated thought in a new session does not get collapsed into an old session's history.
const existingTextFingerprints = new Set();
for (const msg of data.messages) {
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
- existingTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
+ if (!isLeadThoughtLike(msg)) continue;
+ existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
}
const keyFor = (m: {
@@ -428,20 +458,20 @@ async function handleGetData(
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
};
- // Text-based fingerprints for lead_process messages to catch duplicates
- // with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text)
+ // Text-based fingerprints for live lead thoughts to catch duplicates with different
+ // messageIds inside the same session (e.g. lead-turn-* re-emits).
const leadProcessTextFingerprints = new Set();
const merged: typeof data.messages = [];
const seen = new Set();
for (const msg of [...data.messages, ...live]) {
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
- const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
- // Skip if same text already exists from any source (inbox, lead_session, etc.)
+ const fp = getLeadThoughtFingerprint(msg);
+ // Skip if the same thought already exists in persisted history for the same session.
if (existingTextFingerprints.has(fp)) {
continue;
}
- // Dedup lead_process messages with same text but different messageIds
+ // Dedup live lead_process thoughts with the same text in the same session.
if (leadProcessTextFingerprints.has(fp)) {
continue;
}
@@ -466,7 +496,10 @@ async function handleDeleteTeam(
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
- return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!));
+ return wrapTeamHandler('deleteTeam', async () => {
+ getTeamProvisioningService().stopTeam(validated.value!);
+ await getTeamDataService().deleteTeam(validated.value!);
+ });
}
async function handleRestoreTeam(
@@ -641,6 +674,30 @@ async function validateProvisioningRequest(
return { valid: false, error: 'cwd must be a directory' };
}
+ if (payload.worktree !== undefined) {
+ if (typeof payload.worktree !== 'string') {
+ return { valid: false, error: 'worktree must be a string' };
+ }
+ const wt = payload.worktree.trim();
+ if (wt.length > 128) {
+ return { valid: false, error: 'worktree name too long (max 128)' };
+ }
+ if (wt && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(wt)) {
+ return {
+ valid: false,
+ error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]',
+ };
+ }
+ }
+ if (payload.extraCliArgs !== undefined) {
+ if (typeof payload.extraCliArgs !== 'string') {
+ return { valid: false, error: 'extraCliArgs must be a string' };
+ }
+ if (payload.extraCliArgs.length > 1024) {
+ return { valid: false, error: 'extraCliArgs too long (max 1024)' };
+ }
+ }
+
return {
valid: true,
value: {
@@ -655,6 +712,14 @@ async function validateProvisioningRequest(
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
+ worktree:
+ typeof payload.worktree === 'string' && payload.worktree.trim()
+ ? payload.worktree.trim()
+ : undefined,
+ extraCliArgs:
+ typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
+ ? payload.extraCliArgs.trim()
+ : undefined,
},
};
}
@@ -763,6 +828,12 @@ async function handleLaunchTeam(
clearContext: payload.clearContext === true ? true : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
+ worktree:
+ typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
+ extraCliArgs:
+ typeof payload.extraCliArgs === 'string'
+ ? payload.extraCliArgs.trim() || undefined
+ : undefined,
},
(progress) => {
try {
@@ -776,6 +847,32 @@ async function handleLaunchTeam(
);
}
+async function handleValidateCliArgs(
+ _event: IpcMainInvokeEvent,
+ rawArgs: unknown
+): Promise> {
+ if (typeof rawArgs !== 'string') {
+ return { success: false, error: 'rawArgs must be a string' };
+ }
+ if (rawArgs.length > 2048) {
+ return { success: false, error: 'rawArgs too long (max 2048)' };
+ }
+ return wrapTeamHandler('validateCliArgs', async () => {
+ const helpOutput = await getTeamProvisioningService().getCliHelpOutput();
+ const knownFlags = extractFlagsFromHelp(helpOutput);
+ const userFlags = extractUserFlags(rawArgs);
+
+ const invalidFlags = userFlags.filter((f) => !knownFlags.has(f));
+ const protectedFlags = userFlags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
+ const allBad = [...new Set([...invalidFlags, ...protectedFlags])];
+
+ return {
+ valid: allBad.length === 0,
+ invalidFlags: allBad.length > 0 ? allBad : undefined,
+ };
+ });
+}
+
async function handlePrepareProvisioning(
_event: IpcMainInvokeEvent,
cwd: unknown
@@ -906,6 +1003,37 @@ function validateAttachments(
return { valid: true, value: result };
}
+function buildMessageDeliveryText(
+ baseText: string,
+ opts: {
+ actionMode?: AgentActionMode;
+ isLeadRecipient: boolean;
+ }
+): string {
+ const hiddenBlocks: string[] = [];
+ const actionModeBlock = buildActionModeAgentBlock(opts.actionMode);
+ if (actionModeBlock) {
+ hiddenBlocks.push(actionModeBlock);
+ }
+ if (!opts.isLeadRecipient) {
+ hiddenBlocks.push(
+ [
+ AGENT_BLOCK_OPEN,
+ 'You received a direct message from the human user via the UI.',
+ 'Please reply back to recipient "user" with a short, human-readable answer.',
+ 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
+ AGENT_BLOCK_CLOSE,
+ ].join('\n')
+ );
+ }
+
+ if (hiddenBlocks.length === 0) {
+ return baseText;
+ }
+
+ return [...hiddenBlocks, baseText].join('\n\n');
+}
+
async function handleSendMessage(
_event: IpcMainInvokeEvent,
teamName: unknown,
@@ -937,6 +1065,9 @@ async function handleSendMessage(
return { success: false, error: validatedFrom.error ?? 'Invalid from' };
}
}
+ if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) {
+ return { success: false, error: 'actionMode must be one of: do, ask, delegate' };
+ }
let validatedAttachments: AttachmentPayload[] | undefined;
if (
@@ -951,14 +1082,41 @@ async function handleSendMessage(
validatedAttachments = attResult.value;
}
+ const tn = validatedTeamName.value!;
+ const memberName = validatedMember.value!;
+ let prevalidatedLeadName: string | null | undefined;
+ let prevalidatedIsLeadRecipient: boolean | undefined;
+ if (payload.actionMode === 'delegate') {
+ try {
+ prevalidatedLeadName = await getTeamDataService().getLeadMemberName(tn);
+ } catch (error) {
+ return wrapTeamHandler('sendMessage', async () => {
+ throw error;
+ });
+ }
+ prevalidatedIsLeadRecipient =
+ prevalidatedLeadName !== null && memberName === prevalidatedLeadName;
+ if (!prevalidatedIsLeadRecipient) {
+ return {
+ success: false,
+ error: 'Delegate mode is only supported when messaging the team lead',
+ };
+ }
+ }
+
return wrapTeamHandler('sendMessage', async () => {
- const tn = validatedTeamName.value!;
const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn);
- const leadName = await getTeamDataService().getLeadMemberName(tn);
- const memberName = validatedMember.value!;
- const isLeadRecipient = leadName !== null && memberName === leadName;
+ const leadName =
+ prevalidatedLeadName !== undefined
+ ? prevalidatedLeadName
+ : await getTeamDataService().getLeadMemberName(tn);
+ const isLeadRecipient =
+ prevalidatedIsLeadRecipient !== undefined
+ ? prevalidatedIsLeadRecipient
+ : leadName !== null && memberName === leadName;
+ const actionMode = payload.actionMode;
// Attachments only supported for live lead (stdin content blocks)
if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) {
@@ -969,6 +1127,7 @@ async function handleSendMessage(
// Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) {
+ const resolvedLeadName = leadName ?? memberName;
// Separate try blocks: stdin delivery vs persistence
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
// Wrap with instructions so lead responds with visible text (not just agent-only blocks)
@@ -977,7 +1136,10 @@ async function handleSendMessage(
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
``,
`Message from user:`,
- payload.text!,
+ buildMessageDeliveryText(payload.text!, {
+ actionMode,
+ isLeadRecipient: true,
+ }),
].join('\n');
let stdinSent = false;
@@ -1010,7 +1172,7 @@ async function handleSendMessage(
try {
result = await getTeamDataService().sendDirectToLead(
tn,
- leadName,
+ resolvedLeadName,
payload.text!,
payload.summary,
attachmentMeta
@@ -1029,7 +1191,7 @@ async function handleSendMessage(
provisioning.pushLiveLeadProcessMessage(tn, {
from: 'user',
- to: leadName,
+ to: resolvedLeadName,
text: payload.text!,
timestamp: new Date().toISOString(),
read: true,
@@ -1045,17 +1207,10 @@ async function handleSendMessage(
// Inbox path: offline lead or regular members (no attachment support)
const baseText = payload.text!.trim();
- const memberDeliveryText = isLeadRecipient
- ? baseText
- : [
- baseText,
- '',
- AGENT_BLOCK_OPEN,
- 'You received a direct message from the human user via the UI.',
- 'Please reply back to recipient "user" with a short, human-readable answer.',
- 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
- AGENT_BLOCK_CLOSE,
- ].join('\n');
+ const memberDeliveryText = buildMessageDeliveryText(baseText, {
+ actionMode,
+ isLeadRecipient,
+ });
const result = await getTeamDataService().sendMessage(tn, {
member: memberName,
text: memberDeliveryText,
@@ -1063,13 +1218,12 @@ async function handleSendMessage(
from: payload.from,
});
- // Best-effort: if team is alive and recipient is a teammate (not lead),
- // also forward via the live lead process so in-process teammates receive it.
+ // Best-effort live relay so active processes see the inbox row promptly.
if (!isLeadRecipient && isAlive) {
try {
- await provisioning.forwardUserDmToTeammate(tn, memberName, baseText, payload.summary);
+ await provisioning.relayMemberInboxMessages(tn, memberName);
} catch (e: unknown) {
- logger.warn(`Failed to forward user DM to teammate "${memberName}" via lead: ${String(e)}`);
+ logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
}
}
@@ -1635,6 +1789,19 @@ async function handleLeadContext(
);
}
+async function handleMemberSpawnStatuses(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown
+): Promise>> {
+ const validated = validateTeamName(teamName);
+ if (!validated.valid) {
+ return { success: false, error: validated.error ?? 'Invalid teamName' };
+ }
+ return wrapTeamHandler('memberSpawnStatuses', async () =>
+ getTeamProvisioningService().getMemberSpawnStatuses(validated.value!)
+ );
+}
+
async function handleStopTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
@@ -1691,12 +1858,19 @@ async function handleAddMember(
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' };
}
- const { name, role } = payload as { name?: unknown; role?: unknown };
+ const { name, role, workflow } = payload as {
+ name?: unknown;
+ role?: unknown;
+ workflow?: unknown;
+ };
const vName = validateTeammateName(name);
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
if (role !== undefined && typeof role !== 'string') {
return { success: false, error: 'role must be a string' };
}
+ if (workflow !== undefined && typeof workflow !== 'string') {
+ return { success: false, error: 'workflow must be a string' };
+ }
return wrapTeamHandler('addMember', async () => {
const tn = vTeam.value!;
@@ -1704,15 +1878,20 @@ async function handleAddMember(
await getTeamDataService().addMember(tn, {
name: memberName,
role: role,
+ workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
});
// If team is alive, notify the lead to spawn the new teammate
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const roleHint = typeof role === 'string' && role.trim() ? ` with role "${role.trim()}"` : '';
+ const workflowHint =
+ typeof workflow === 'string' && workflow.trim()
+ ? ` Their workflow: ${workflow.trim()}`
+ : '';
const spawnMessage =
`A new teammate "${memberName}"${roleHint} has been added to the team. ` +
- `Please spawn them immediately using the Task tool with team_name="${tn}" and name="${memberName}".`;
+ `Please spawn them immediately using the Task tool with team_name="${tn}" and name="${memberName}".${workflowHint}`;
try {
await provisioning.sendMessageToTeam(tn, spawnMessage);
} catch {
@@ -2307,3 +2486,40 @@ async function handleToolApprovalRespond(
)
);
}
+
+async function handleToolApprovalSettings(
+ _event: IpcMainInvokeEvent,
+ settings: unknown
+): Promise> {
+ if (typeof settings !== 'object' || settings === null) {
+ return { success: false, error: 'Settings must be an object' };
+ }
+ const s = settings as Record;
+ if (typeof s.autoAllowFileEdits !== 'boolean') {
+ return { success: false, error: 'autoAllowFileEdits must be a boolean' };
+ }
+ if (typeof s.autoAllowSafeBash !== 'boolean') {
+ return { success: false, error: 'autoAllowSafeBash must be a boolean' };
+ }
+ if (typeof s.timeoutAction !== 'string' || !['allow', 'deny', 'wait'].includes(s.timeoutAction)) {
+ return { success: false, error: 'timeoutAction must be "allow", "deny", or "wait"' };
+ }
+ if (
+ typeof s.timeoutSeconds !== 'number' ||
+ !Number.isFinite(s.timeoutSeconds) ||
+ s.timeoutSeconds < 5 ||
+ s.timeoutSeconds > 300
+ ) {
+ return { success: false, error: 'timeoutSeconds must be a number between 5 and 300' };
+ }
+
+ try {
+ getTeamProvisioningService().updateToolApprovalSettings(s as unknown as ToolApprovalSettings);
+ } catch (err) {
+ return {
+ success: false,
+ error: `Failed to update tool approval settings: ${err instanceof Error ? err.message : String(err)}`,
+ };
+ }
+ return { success: true, data: undefined };
+}
diff --git a/src/main/services/discovery/SubagentLocator.ts b/src/main/services/discovery/SubagentLocator.ts
index 86092d89..961d2620 100644
--- a/src/main/services/discovery/SubagentLocator.ts
+++ b/src/main/services/discovery/SubagentLocator.ts
@@ -4,10 +4,8 @@
* Responsibilities:
* - Check if sessions have subagent files
* - List subagent files for a session
- * - Handle both NEW and OLD subagent directory structures:
- * - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
- * - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported)
- * - Determine subagent ownership for OLD structure
+ * - Handle the canonical subagent directory structure:
+ * - {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
*/
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
@@ -91,110 +89,25 @@ export class SubagentLocator {
}
/**
- * Lists all subagent files for a session from both NEW and OLD structures.
- * Returns NEW structure files first, then OLD structure files.
- *
- * @param projectId - The project ID
- * @param sessionId - The session ID
- * @returns Promise resolving to array of file paths
+ * Lists all subagent files for a session from the canonical session-local structure.
*/
async listSubagentFiles(projectId: string, sessionId: string): Promise {
- const allFiles: string[] = [];
-
try {
- // Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (await this.fsProvider.exists(newSubagentsPath)) {
const entries = await this.fsProvider.readdir(newSubagentsPath);
- const newFiles = entries
+ return entries
.filter(
(entry) =>
entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
)
.map((entry) => path.join(newSubagentsPath, entry.name));
- allFiles.push(...newFiles);
}
} catch (error) {
- logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error);
+ logger.error(`Error scanning subagent structure for session ${sessionId}:`, error);
}
- try {
- // Scan OLD structure: {projectId}/agent-*.jsonl
- // Must filter by sessionId since all sessions share the same project root
- const oldFiles = await this.getProjectRootSubagentFiles(projectId, sessionId);
- allFiles.push(...oldFiles);
- } catch (error) {
- logger.error(`Error scanning OLD subagent structure for project ${projectId}:`, error);
- }
-
- return allFiles;
- }
-
- /**
- * Gets subagent files from project root (OLD structure).
- * Scans {projectId}/agent-*.jsonl files and filters by sessionId.
- *
- * In the OLD structure, all subagent files are in the project root,
- * so we must read each file's first line to check if it belongs to the session.
- *
- * @param projectId - The project ID
- * @param sessionId - The session ID
- * @returns Promise resolving to array of file paths
- */
- async getProjectRootSubagentFiles(projectId: string, sessionId: string): Promise {
- try {
- const projectPath = path.join(this.projectsDir, extractBaseDir(projectId));
-
- if (!(await this.fsProvider.exists(projectPath))) {
- return [];
- }
-
- const entries = await this.fsProvider.readdir(projectPath);
- const agentFiles = entries
- .filter((entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl'))
- .map((entry) => path.join(projectPath, entry.name));
-
- // Filter files by checking if their sessionId matches
- const matchingFiles: string[] = [];
- for (const filePath of agentFiles) {
- if (await this.subagentBelongsToSession(filePath, sessionId)) {
- matchingFiles.push(filePath);
- }
- }
-
- return matchingFiles;
- } catch (error) {
- logger.error(`Error reading project root for subagent files:`, error);
- return [];
- }
- }
-
- /**
- * Checks if a subagent file belongs to a specific session by reading its first line.
- * Subagent files have a sessionId field that points to the parent session.
- *
- * @param filePath - Path to the subagent file
- * @param sessionId - The session ID to check
- * @returns Promise resolving to true if the subagent belongs to the session
- */
- async subagentBelongsToSession(filePath: string, sessionId: string): Promise {
- try {
- // Read just the first line to check sessionId
- const content = await this.fsProvider.readFile(filePath);
- const firstNewline = content.indexOf('\n');
- const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content;
-
- if (!firstLine.trim()) {
- return false;
- }
-
- const entry = JSON.parse(firstLine) as { sessionId?: string };
- return entry.sessionId === sessionId;
- } catch (error) {
- // If we can't read or parse the file, don't include it - log for debugging
- logger.debug(`SubagentLocator: Could not parse file ${filePath}:`, error);
- return false;
- }
+ return [];
}
/**
diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts
index 1aaf2eed..28d888c3 100644
--- a/src/main/services/error/ErrorMessageBuilder.ts
+++ b/src/main/services/error/ErrorMessageBuilder.ts
@@ -57,7 +57,9 @@ export interface DetectedError {
| 'lead_inbox'
| 'user_inbox'
| 'task_clarification'
- | 'task_status_change';
+ | 'task_status_change'
+ | 'schedule_completed'
+ | 'schedule_failed';
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
dedupeKey?: string;
/** Additional context about the error */
diff --git a/src/main/services/extensions/ExtensionFacadeService.ts b/src/main/services/extensions/ExtensionFacadeService.ts
new file mode 100644
index 00000000..7a5df4f9
--- /dev/null
+++ b/src/main/services/extensions/ExtensionFacadeService.ts
@@ -0,0 +1,134 @@
+/**
+ * Facade service that combines plugin catalog + MCP catalog + installation state
+ * into enriched data ready for the renderer.
+ *
+ * Also provides install target resolution for the security model
+ * (main-side re-resolution: renderer sends pluginId/registryId, main resolves from catalog).
+ */
+
+import { createLogger } from '@shared/utils/logger';
+import type {
+ EnrichedPlugin,
+ InstalledMcpEntry,
+ McpCatalogItem,
+ McpSearchResult,
+ PluginCatalogItem,
+} from '@shared/types/extensions';
+
+import { PluginCatalogService } from './catalog/PluginCatalogService';
+import { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
+import { PluginInstallationStateService } from './state/PluginInstallationStateService';
+import { McpInstallationStateService } from './state/McpInstallationStateService';
+
+const logger = createLogger('Extensions:Facade');
+
+export class ExtensionFacadeService {
+ constructor(
+ private readonly pluginCatalog: PluginCatalogService,
+ private readonly pluginState: PluginInstallationStateService,
+ private readonly mcpAggregator: McpCatalogAggregator | null = null,
+ private readonly mcpState: McpInstallationStateService | null = null
+ ) {}
+
+ // ── Plugin methods ───────────────────────────────────────────────────
+
+ /**
+ * Get all plugins enriched with install status and counts.
+ */
+ async getEnrichedPlugins(projectPath?: string, forceRefresh = false): Promise {
+ const [catalog, installed, counts] = await Promise.all([
+ this.pluginCatalog.getPlugins(forceRefresh),
+ this.pluginState.getInstalledPlugins(projectPath),
+ this.pluginState.getInstallCounts(),
+ ]);
+
+ // Build installed lookup: pluginId → entries[]
+ const installedMap = new Map();
+ for (const entry of installed) {
+ const list = installedMap.get(entry.pluginId) ?? [];
+ list.push(entry);
+ installedMap.set(entry.pluginId, list);
+ }
+
+ return catalog.map((item): EnrichedPlugin => {
+ const installations = installedMap.get(item.pluginId) ?? [];
+ const installCount = counts.get(item.pluginId) ?? 0;
+
+ return {
+ ...item,
+ installCount,
+ isInstalled: installations.length > 0,
+ installations,
+ };
+ });
+ }
+
+ /**
+ * Get README content for a plugin.
+ */
+ async getPluginReadme(pluginId: string): Promise {
+ return this.pluginCatalog.getPluginReadme(pluginId);
+ }
+
+ /**
+ * Resolve a pluginId to its install target.
+ */
+ async resolvePluginInstallTarget(
+ pluginId: string
+ ): Promise<{ qualifiedName: string; plugin: PluginCatalogItem } | null> {
+ const plugin = await this.pluginCatalog.resolvePlugin(pluginId);
+ if (!plugin) {
+ logger.warn(`Cannot resolve install target: pluginId "${pluginId}" not found in catalog`);
+ return null;
+ }
+ return { qualifiedName: plugin.qualifiedName, plugin };
+ }
+
+ // ── MCP methods ──────────────────────────────────────────────────────
+
+ /**
+ * Search MCP servers across both registries.
+ */
+ async searchMcp(query: string, limit?: number): Promise {
+ if (!this.mcpAggregator) {
+ return { servers: [], warnings: ['MCP catalog not configured'] };
+ }
+ return this.mcpAggregator.search(query, limit);
+ }
+
+ /**
+ * Browse MCP catalog with pagination.
+ */
+ async browseMcp(
+ cursor?: string,
+ limit?: number
+ ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
+ if (!this.mcpAggregator) {
+ return { servers: [] };
+ }
+ return this.mcpAggregator.browse(cursor, limit);
+ }
+
+ /**
+ * Get a single MCP server by registry ID (for install flow).
+ */
+ async getMcpById(registryId: string): Promise {
+ if (!this.mcpAggregator) return null;
+ return this.mcpAggregator.getById(registryId);
+ }
+
+ /**
+ * Get installed MCP servers.
+ */
+ async getInstalledMcp(projectPath?: string): Promise {
+ if (!this.mcpState) return [];
+ return this.mcpState.getInstalled(projectPath);
+ }
+
+ // ── Cache invalidation ───────────────────────────────────────────────
+
+ invalidateInstalledCache(): void {
+ this.pluginState.invalidateCache();
+ this.mcpState?.invalidateCache();
+ }
+}
diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts
new file mode 100644
index 00000000..be754ad3
--- /dev/null
+++ b/src/main/services/extensions/apikeys/ApiKeyService.ts
@@ -0,0 +1,398 @@
+/**
+ * ApiKeyService — encrypted API key storage with CRUD operations.
+ *
+ * Encryption strategy (in priority order):
+ * 1. Electron safeStorage (OS keychain: macOS Keychain / Windows DPAPI / Linux gnome-libsecret/kwallet)
+ * 2. AES-256-GCM with machine-derived key (Linux fallback when no keyring is available)
+ *
+ * File permissions: 0o600 (owner read/write only) on Unix systems.
+ * Storage file: ~/.claude/api-keys.json
+ */
+
+import { safeStorage } from 'electron';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import os from 'node:os';
+import { createLogger } from '@shared/utils/logger';
+import type {
+ ApiKeyEntry,
+ ApiKeyLookupResult,
+ ApiKeySaveRequest,
+ ApiKeyStorageStatus,
+} from '@shared/types/extensions';
+
+const logger = createLogger('Extensions:ApiKeys');
+const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
+
+/** How the value was encrypted on disk */
+type EncryptionMethod = 'safeStorage' | 'aes-local' | 'base64';
+
+interface StoredApiKey {
+ id: string;
+ name: string;
+ envVarName: string;
+ encryptedValue: string;
+ /** @deprecated Use encryptionMethod instead. Kept for migration. */
+ encrypted?: boolean;
+ encryptionMethod?: EncryptionMethod;
+ scope: 'user' | 'project';
+ createdAt: string;
+ updatedAt?: string;
+}
+
+/** AES-256-GCM constants */
+const AES_IV_BYTES = 12;
+const AES_TAG_BYTES = 16;
+const PBKDF2_ITERATIONS = 100_000;
+const PBKDF2_KEY_BYTES = 32;
+const PBKDF2_SALT = 'claude-apikey-storage-v1';
+
+export class ApiKeyService {
+ private readonly filePath: string;
+ private cache: StoredApiKey[] | null = null;
+ private aesKey: Buffer | null = null;
+
+ constructor(claudeDir?: string) {
+ const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
+ this.filePath = path.join(baseDir, 'api-keys.json');
+ }
+
+ // ── Public API ──────────────────────────────────────────────────────────
+
+ async list(): Promise {
+ const keys = await this.readStore();
+ return keys.map((k) => ({
+ id: k.id,
+ name: k.name,
+ envVarName: k.envVarName,
+ maskedValue: this.mask(this.decrypt(k)),
+ scope: k.scope,
+ createdAt: k.createdAt,
+ }));
+ }
+
+ async save(request: ApiKeySaveRequest): Promise {
+ if (!request.name?.trim()) throw new Error('Key name is required');
+ if (!request.envVarName?.trim()) throw new Error('Environment variable name is required');
+ if (!ENV_KEY_RE.test(request.envVarName)) {
+ throw new Error(
+ `Invalid env var name: "${request.envVarName}". Use uppercase letters, digits, underscores.`
+ );
+ }
+ if (!request.value) throw new Error('Key value is required');
+
+ const keys = await this.readStore();
+ const now = new Date().toISOString();
+ const { method, value } = this.encrypt(request.value);
+
+ if (request.id) {
+ const idx = keys.findIndex((k) => k.id === request.id);
+ if (idx === -1) throw new Error(`API key not found: ${request.id}`);
+ keys[idx] = {
+ ...keys[idx],
+ name: request.name.trim(),
+ envVarName: request.envVarName.trim(),
+ encryptedValue: value,
+ encryptionMethod: method,
+ scope: request.scope,
+ updatedAt: now,
+ };
+ // Remove legacy field
+ delete keys[idx].encrypted;
+ } else {
+ keys.push({
+ id: crypto.randomUUID(),
+ name: request.name.trim(),
+ envVarName: request.envVarName.trim(),
+ encryptedValue: value,
+ encryptionMethod: method,
+ scope: request.scope,
+ createdAt: now,
+ });
+ }
+
+ await this.writeStore(keys);
+ const saved = keys[request.id ? keys.findIndex((k) => k.id === request.id) : keys.length - 1];
+ return {
+ id: saved.id,
+ name: saved.name,
+ envVarName: saved.envVarName,
+ maskedValue: this.mask(request.value),
+ scope: saved.scope,
+ createdAt: saved.createdAt,
+ };
+ }
+
+ async delete(id: string): Promise {
+ const keys = await this.readStore();
+ const filtered = keys.filter((k) => k.id !== id);
+ if (filtered.length === keys.length) throw new Error(`API key not found: ${id}`);
+ await this.writeStore(filtered);
+ }
+
+ async lookup(envVarNames: string[]): Promise {
+ if (!envVarNames.length) return [];
+ const keys = await this.readStore();
+ const nameSet = new Set(envVarNames);
+ return keys
+ .filter((k) => nameSet.has(k.envVarName))
+ .map((k) => ({
+ envVarName: k.envVarName,
+ value: this.decrypt(k),
+ }));
+ }
+
+ async getStorageStatus(): Promise {
+ const secure = this.isSecureBackend();
+ const backend = this.getBackendName();
+ let fileSecure = true;
+ if (process.platform !== 'win32') {
+ try {
+ const stat = await fs.stat(this.filePath);
+ fileSecure = (stat.mode & 0o077) === 0;
+ } catch {
+ // File doesn't exist yet — considered secure
+ }
+ }
+ return {
+ encryptionMethod: secure ? 'os-keychain' : 'aes-local',
+ backend,
+ fileSecure,
+ };
+ }
+
+ // ── Encryption ──────────────────────────────────────────────────────────
+
+ /**
+ * Check if the OS provides a real secure backend (not basic_text).
+ * On Linux, safeStorage.isEncryptionAvailable() returns true even with basic_text
+ * backend (hardcoded password), so we must check the actual backend name.
+ */
+ private isSecureBackend(): boolean {
+ try {
+ if (!safeStorage.isEncryptionAvailable()) return false;
+ if (process.platform === 'linux') {
+ const backend = safeStorage.getSelectedStorageBackend();
+ return backend !== 'basic_text' && backend !== 'unknown';
+ }
+ return true; // macOS Keychain / Windows DPAPI are always reliable
+ } catch {
+ return false;
+ }
+ }
+
+ private getBackendName(): string {
+ if (process.platform === 'darwin') return 'macOS Keychain';
+ if (process.platform === 'win32') return 'Windows DPAPI';
+ try {
+ return safeStorage.getSelectedStorageBackend();
+ } catch {
+ return 'unknown';
+ }
+ }
+
+ private encrypt(value: string): { method: EncryptionMethod; value: string } {
+ // Try OS keychain first
+ if (this.isSecureBackend()) {
+ try {
+ const buf = safeStorage.encryptString(value);
+ return { method: 'safeStorage', value: buf.toString('base64') };
+ } catch (err) {
+ logger.warn('safeStorage encryption failed, falling back to AES-local:', err);
+ }
+ }
+
+ // Fallback: AES-256-GCM with machine-derived key
+ return { method: 'aes-local', value: this.encryptAesLocal(value) };
+ }
+
+ private decrypt(stored: StoredApiKey): string {
+ try {
+ const method = this.resolveMethod(stored);
+
+ switch (method) {
+ case 'safeStorage': {
+ const buf = Buffer.from(stored.encryptedValue, 'base64');
+ return safeStorage.decryptString(buf);
+ }
+ case 'aes-local':
+ return this.decryptAesLocal(stored.encryptedValue);
+ case 'base64':
+ return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
+ }
+ } catch (err) {
+ logger.error(`Failed to decrypt API key "${stored.name}":`, err);
+ return '';
+ }
+ }
+
+ /** Resolve encryption method, handling legacy entries without encryptionMethod field */
+ private resolveMethod(stored: StoredApiKey): EncryptionMethod {
+ if (stored.encryptionMethod) return stored.encryptionMethod;
+ // Legacy migration: encrypted boolean → method
+ return stored.encrypted ? 'safeStorage' : 'base64';
+ }
+
+ // ── AES-256-GCM local encryption ───────────────────────────────────────
+
+ /**
+ * Derive an AES key from machine-specific info.
+ * Not as secure as OS keychain (extractable with root), but far better than plaintext.
+ * Protects against casual read by other users.
+ */
+ private getAesKey(): Buffer {
+ if (this.aesKey) return this.aesKey;
+
+ const material = [os.hostname(), os.userInfo().username, this.getMachineId()].join(':');
+
+ this.aesKey = crypto.pbkdf2Sync(
+ material,
+ PBKDF2_SALT,
+ PBKDF2_ITERATIONS,
+ PBKDF2_KEY_BYTES,
+ 'sha512'
+ );
+ return this.aesKey;
+ }
+
+ private getMachineId(): string {
+ if (process.platform !== 'linux') {
+ return `${process.platform}-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
+ }
+ // Linux: try /etc/machine-id (systemd) or /var/lib/dbus/machine-id
+ const candidates = ['/etc/machine-id', '/var/lib/dbus/machine-id'];
+ for (const p of candidates) {
+ try {
+ // Synchronous read is OK — called once, result cached in aesKey
+ const { readFileSync } = require('node:fs') as typeof import('node:fs');
+ const id = readFileSync(p, 'utf-8').trim();
+ if (id) return id;
+ } catch {
+ // Try next candidate
+ }
+ }
+ return `linux-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
+ }
+
+ private encryptAesLocal(value: string): string {
+ const key = this.getAesKey();
+ const iv = crypto.randomBytes(AES_IV_BYTES);
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
+ const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ // Format: iv:tag:ciphertext (all base64)
+ return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
+ }
+
+ private decryptAesLocal(stored: string): string {
+ const parts = stored.split(':');
+ if (parts.length !== 3) throw new Error('Invalid AES-local format');
+ const [ivB64, tagB64, dataB64] = parts;
+ const key = this.getAesKey();
+ const iv = Buffer.from(ivB64, 'base64');
+ const tag = Buffer.from(tagB64, 'base64');
+ const data = Buffer.from(dataB64, 'base64');
+
+ if (iv.length !== AES_IV_BYTES) throw new Error('Invalid IV length');
+ if (tag.length !== AES_TAG_BYTES) throw new Error('Invalid auth tag length');
+
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
+ decipher.setAuthTag(tag);
+ return decipher.update(data).toString('utf-8') + decipher.final('utf-8');
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────────
+
+ private mask(value: string): string {
+ if (value.length <= 3) return '***';
+ return '*'.repeat(Math.min(value.length - 3, 20)) + value.slice(-3);
+ }
+
+ // ── Storage I/O ─────────────────────────────────────────────────────────
+
+ private async readStore(): Promise {
+ if (this.cache) return this.cache;
+ try {
+ const raw = await fs.readFile(this.filePath, 'utf-8');
+ const data = JSON.parse(raw);
+ const keys: StoredApiKey[] = Array.isArray(data) ? data : [];
+
+ // Fix file permissions if insecure
+ await this.ensureFilePermissions();
+
+ // Migrate legacy entries (base64 → current method)
+ const migrated = this.migrateKeys(keys);
+ if (migrated) {
+ await this.writeStore(keys);
+ }
+
+ this.cache = keys;
+ } catch {
+ this.cache = [];
+ }
+ return this.cache;
+ }
+
+ /**
+ * Migrate legacy entries: re-encrypt base64 entries with current best method.
+ * Returns true if any entries were migrated.
+ */
+ private migrateKeys(keys: StoredApiKey[]): boolean {
+ let migrated = false;
+ for (let i = 0; i < keys.length; i++) {
+ const method = this.resolveMethod(keys[i]);
+ if (method === 'base64') {
+ try {
+ const plaintext = Buffer.from(keys[i].encryptedValue, 'base64').toString('utf-8');
+ if (!plaintext) continue;
+ const { method: newMethod, value } = this.encrypt(plaintext);
+ keys[i] = {
+ ...keys[i],
+ encryptedValue: value,
+ encryptionMethod: newMethod,
+ };
+ delete keys[i].encrypted;
+ migrated = true;
+ logger.info(`Migrated API key "${keys[i].name}" from base64 to ${newMethod}`);
+ } catch (err) {
+ logger.warn(`Failed to migrate API key "${keys[i].name}":`, err);
+ }
+ }
+ }
+ return migrated;
+ }
+
+ private async writeStore(keys: StoredApiKey[]): Promise {
+ const dir = path.dirname(this.filePath);
+ await fs.mkdir(dir, { recursive: true });
+ const tmpPath = this.filePath + `.tmp.${crypto.randomUUID()}`;
+ await fs.writeFile(tmpPath, JSON.stringify(keys, null, 2), 'utf-8');
+
+ // Set restrictive permissions before rename (Unix only)
+ if (process.platform !== 'win32') {
+ try {
+ await fs.chmod(tmpPath, 0o600);
+ } catch {
+ // Best-effort — Windows or restricted FS
+ }
+ }
+
+ await fs.rename(tmpPath, this.filePath);
+ this.cache = keys;
+ }
+
+ /** Ensure the storage file has owner-only permissions */
+ private async ensureFilePermissions(): Promise {
+ if (process.platform === 'win32') return;
+ try {
+ const stat = await fs.stat(this.filePath);
+ if ((stat.mode & 0o077) !== 0) {
+ logger.warn('API keys file has insecure permissions, fixing to 0600');
+ await fs.chmod(this.filePath, 0o600);
+ }
+ } catch {
+ // File may not exist yet
+ }
+ }
+}
diff --git a/src/main/services/extensions/catalog/GitHubStarsService.ts b/src/main/services/extensions/catalog/GitHubStarsService.ts
new file mode 100644
index 00000000..99ebfa6c
--- /dev/null
+++ b/src/main/services/extensions/catalog/GitHubStarsService.ts
@@ -0,0 +1,184 @@
+/**
+ * GitHubStarsService — fetches stargazer counts from the GitHub public API.
+ *
+ * - Batch interface: accepts repository URLs, returns map of URL → stars
+ * - In-memory cache with 1-hour TTL
+ * - Concurrency-limited to 5 parallel requests
+ * - Silent failure: 404, timeout, rate-limit → entry skipped
+ */
+
+import https from 'node:https';
+
+import { createLogger } from '@shared/utils/logger';
+import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
+
+const logger = createLogger('Extensions:GitHubStars');
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const CACHE_TTL_MS = 60 * 60_000; // 1 hour
+const HTTP_TIMEOUT_MS = 10_000; // 10 seconds
+const MAX_CONCURRENCY = 5;
+const MAX_BODY_SIZE = 256 * 1024; // 256KB (GitHub repo JSON is ~10KB)
+
+// ── Cache entry ────────────────────────────────────────────────────────────
+
+interface CacheEntry {
+ stars: number;
+ fetchedAt: number;
+}
+
+// ── Service ────────────────────────────────────────────────────────────────
+
+export class GitHubStarsService {
+ private cache = new Map();
+
+ /**
+ * Fetch GitHub stars for a batch of repository URLs.
+ * Returns `Record` — only includes URLs with valid results.
+ */
+ async fetchStars(repositoryUrls: string[]): Promise> {
+ const result: Record = {};
+ const tasks: Array<{ url: string; owner: string; repo: string }> = [];
+
+ for (const url of repositoryUrls) {
+ const parsed = parseGitHubOwnerRepo(url);
+ if (!parsed) continue;
+
+ const cacheKey = `${parsed.owner}/${parsed.repo}`.toLowerCase();
+ const cached = this.cache.get(cacheKey);
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
+ result[url] = cached.stars;
+ continue;
+ }
+
+ tasks.push({ url, owner: parsed.owner, repo: parsed.repo });
+ }
+
+ if (tasks.length === 0) return result;
+
+ // Fetch with concurrency limit
+ const outcomes = await this.withConcurrencyLimit(
+ tasks.map(({ url, owner, repo }) => async () => {
+ const stars = await this.fetchSingle(owner, repo);
+ if (stars != null) {
+ const cacheKey = `${owner}/${repo}`.toLowerCase();
+ this.cache.set(cacheKey, { stars, fetchedAt: Date.now() });
+ result[url] = stars;
+ }
+ }),
+ MAX_CONCURRENCY
+ );
+
+ // Log any failures for debugging (already silent to caller)
+ const failures = outcomes.filter((o) => o === 'error').length;
+ if (failures > 0) {
+ logger.debug(
+ `GitHub stars: ${tasks.length - failures}/${tasks.length} fetched, ${failures} failed`
+ );
+ }
+
+ return result;
+ }
+
+ /**
+ * Fetch stargazer count for a single repo. Returns null on any error.
+ */
+ private async fetchSingle(owner: string, repo: string): Promise {
+ try {
+ const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
+ const { statusCode, body } = await githubGet(url);
+
+ if (statusCode !== 200) {
+ if (statusCode === 403 || statusCode === 429) {
+ logger.debug(`GitHub API rate limit hit (${statusCode}), skipping remaining`);
+ }
+ return null;
+ }
+
+ const data = JSON.parse(body) as { stargazers_count?: number };
+ return typeof data.stargazers_count === 'number' ? data.stargazers_count : null;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Run async tasks with a concurrency limit.
+ */
+ private async withConcurrencyLimit(
+ tasks: Array<() => Promise>,
+ limit: number
+ ): Promise> {
+ const results: Array<'ok' | 'error'> = [];
+ let index = 0;
+
+ const run = async (): Promise => {
+ while (index < tasks.length) {
+ const i = index++;
+ try {
+ await tasks[i]();
+ results[i] = 'ok';
+ } catch {
+ results[i] = 'error';
+ }
+ }
+ };
+
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => run());
+ await Promise.all(workers);
+ return results;
+ }
+}
+
+// ── HTTP helper (GitHub-specific) ──────────────────────────────────────────
+
+function githubGet(url: string): Promise<{ statusCode: number; body: string }> {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+
+ const settleResolve = (v: { statusCode: number; body: string }): void => {
+ if (!settled) {
+ settled = true;
+ resolve(v);
+ }
+ };
+ const settleReject = (e: Error): void => {
+ if (!settled) {
+ settled = true;
+ reject(e);
+ }
+ };
+
+ const req = https.get(
+ url,
+ {
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ 'User-Agent': 'claude-devtools',
+ },
+ },
+ (res) => {
+ const status = res.statusCode ?? 0;
+ const chunks: Buffer[] = [];
+ let totalSize = 0;
+
+ res.on('data', (c: Buffer) => {
+ totalSize += c.length;
+ if (totalSize > MAX_BODY_SIZE) {
+ res.destroy(new Error('Response too large'));
+ return;
+ }
+ chunks.push(c);
+ });
+ res.on('end', () =>
+ settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
+ );
+ res.on('error', settleReject);
+ }
+ );
+
+ req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout: ${url}`)));
+ req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
+ });
+}
diff --git a/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts b/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts
new file mode 100644
index 00000000..01ec4230
--- /dev/null
+++ b/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts
@@ -0,0 +1,189 @@
+/**
+ * Fetches MCP server data from the Glama.ai API.
+ *
+ * Optional enrichment layer — NOT a hard dependency.
+ * Provides: license, tools, Glama URL.
+ * Does NOT provide install info (no packages/remotes).
+ *
+ * Base URL: https://glama.ai/api/mcp/v1/servers
+ * Cursor-based pagination (after), no auth required.
+ */
+
+import https from 'node:https';
+import http from 'node:http';
+
+import { createLogger } from '@shared/utils/logger';
+import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
+
+const logger = createLogger('Extensions:GlamaMcp');
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const GLAMA_BASE_URL = 'https://glama.ai/api/mcp/v1/servers';
+const HTTP_TIMEOUT_MS = 15_000;
+const MAX_REDIRECTS = 5;
+const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
+
+// ── HTTP helper ────────────────────────────────────────────────────────────
+
+function httpGet(
+ url: string,
+ redirectsLeft = MAX_REDIRECTS
+): Promise<{ statusCode: number; body: string }> {
+ return new Promise((resolve, reject) => {
+ const parsedUrl = new URL(url);
+ const transport = parsedUrl.protocol === 'http:' ? http : https;
+ let settled = false;
+
+ const settleResolve = (v: { statusCode: number; body: string }): void => {
+ if (!settled) {
+ settled = true;
+ resolve(v);
+ }
+ };
+ const settleReject = (e: Error): void => {
+ if (!settled) {
+ settled = true;
+ reject(e);
+ }
+ };
+
+ const req = transport.get(url, (res) => {
+ const status = res.statusCode ?? 0;
+ if (status >= 300 && status < 400 && res.headers.location) {
+ if (redirectsLeft <= 0) {
+ res.destroy();
+ settleReject(new Error('Too many redirects'));
+ return;
+ }
+ res.destroy();
+ httpGet(new URL(res.headers.location, url).toString(), redirectsLeft - 1).then(
+ settleResolve,
+ settleReject
+ );
+ return;
+ }
+ const chunks: Buffer[] = [];
+ let totalSize = 0;
+ res.on('data', (c: Buffer) => {
+ totalSize += c.length;
+ if (totalSize > MAX_BODY_SIZE) {
+ res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
+ return;
+ }
+ chunks.push(c);
+ });
+ res.on('end', () =>
+ settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
+ );
+ res.on('error', settleReject);
+ });
+ req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`)));
+ req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
+ });
+}
+
+// ── Raw Glama API shapes ───────────────────────────────────────────────────
+
+interface GlamaResponse {
+ pageInfo: {
+ endCursor?: string;
+ hasNextPage?: boolean;
+ };
+ servers: GlamaServer[];
+}
+
+interface GlamaServer {
+ id: string;
+ name: string;
+ namespace?: string;
+ description?: string;
+ slug?: string;
+ url?: string;
+ repository?: { url: string };
+ spdxLicense?: { name: string; url?: string } | null;
+ tools?: { name?: string; description?: string }[];
+ attributes?: string[];
+}
+
+// ── Service ────────────────────────────────────────────────────────────────
+
+export class GlamaMcpEnrichmentService {
+ /**
+ * Search Glama for MCP servers.
+ */
+ async search(query: string, limit = 20): Promise {
+ const params = new URLSearchParams({ search: query, first: String(limit) });
+ const url = `${GLAMA_BASE_URL}?${params}`;
+
+ try {
+ const resp = await httpGet(url);
+ if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
+ const json = JSON.parse(resp.body) as GlamaResponse;
+ return json.servers.map((s) => this.normalize(s));
+ } catch (err) {
+ logger.warn('Glama MCP search failed:', err);
+ return [];
+ }
+ }
+
+ /**
+ * Browse Glama catalog with cursor pagination.
+ */
+ async browse(
+ cursor?: string,
+ limit = 20
+ ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
+ const params = new URLSearchParams({ first: String(limit) });
+ if (cursor) params.set('after', cursor);
+ const url = `${GLAMA_BASE_URL}?${params}`;
+
+ try {
+ const resp = await httpGet(url);
+ if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
+ const json = JSON.parse(resp.body) as GlamaResponse;
+ return {
+ servers: json.servers.map((s) => this.normalize(s)),
+ nextCursor: json.pageInfo.hasNextPage ? json.pageInfo.endCursor : undefined,
+ };
+ } catch (err) {
+ logger.warn('Glama MCP browse failed:', err);
+ return { servers: [] };
+ }
+ }
+
+ // ── Private ────────────────────────────────────────────────────────────
+
+ private normalize(raw: GlamaServer): McpCatalogItem {
+ const tools: McpToolDef[] = (raw.tools ?? [])
+ .filter((t): t is { name: string; description: string } => Boolean(t.name))
+ .map((t) => ({ name: t.name, description: t.description ?? '' }));
+
+ return {
+ id: `glama:${raw.id}`,
+ name: raw.name,
+ description: raw.description ?? '',
+ repositoryUrl: raw.repository?.url,
+ version: undefined, // Glama doesn't expose version
+ source: 'glama',
+ installSpec: null, // Glama has NO install info
+ envVars: [],
+ license: raw.spdxLicense?.name,
+ tools,
+ glamaUrl: raw.url,
+ requiresAuth: false,
+ author: raw.namespace,
+ hostingType: this.deriveHostingType(raw.attributes),
+ };
+ }
+
+ private deriveHostingType(attributes?: string[]): McpHostingType | undefined {
+ if (!attributes?.length) return undefined;
+ const hasLocal = attributes.includes('hosting:local-only');
+ const hasRemote = attributes.includes('hosting:remote-capable');
+ if (hasLocal && hasRemote) return 'both';
+ if (hasLocal) return 'local';
+ if (hasRemote) return 'remote';
+ return undefined;
+ }
+}
diff --git a/src/main/services/extensions/catalog/McpCatalogAggregator.ts b/src/main/services/extensions/catalog/McpCatalogAggregator.ts
new file mode 100644
index 00000000..11de57e4
--- /dev/null
+++ b/src/main/services/extensions/catalog/McpCatalogAggregator.ts
@@ -0,0 +1,149 @@
+/**
+ * Aggregates MCP catalog data from Official Registry + Glama.
+ *
+ * - Uses Promise.allSettled so partial API failures don't break the whole catalog
+ * - Dedup by repository URL; Official source takes priority
+ * - Enriches Official entries with Glama data (license, tools) when matched
+ * - Provides getById() for secure install flow
+ */
+
+import { createLogger } from '@shared/utils/logger';
+import type { McpCatalogItem, McpSearchResult } from '@shared/types/extensions';
+import { normalizeRepoUrl } from '@shared/utils/extensionNormalizers';
+
+import { OfficialMcpRegistryService } from './OfficialMcpRegistryService';
+import { GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService';
+
+const logger = createLogger('Extensions:McpAggregator');
+
+export class McpCatalogAggregator {
+ constructor(
+ private readonly official: OfficialMcpRegistryService,
+ private readonly glama: GlamaMcpEnrichmentService
+ ) {}
+
+ /**
+ * Search both sources and return merged results.
+ */
+ async search(query: string, limit = 20): Promise {
+ const warnings: string[] = [];
+
+ const [officialResult, glamaResult] = await Promise.allSettled([
+ this.official.search(query, limit),
+ this.glama.search(query, limit),
+ ]);
+
+ const officialServers = officialResult.status === 'fulfilled' ? officialResult.value : [];
+ const glamaServers = glamaResult.status === 'fulfilled' ? glamaResult.value : [];
+
+ if (officialResult.status === 'rejected') {
+ warnings.push('Official MCP Registry unavailable');
+ logger.warn('Official registry search failed:', officialResult.reason);
+ }
+ if (glamaResult.status === 'rejected') {
+ warnings.push('Glama enrichment unavailable');
+ logger.warn('Glama search failed:', glamaResult.reason);
+ }
+
+ const merged = this.mergeAndDeduplicate(officialServers, glamaServers);
+
+ return { servers: merged, warnings };
+ }
+
+ /**
+ * Browse the official registry with optional Glama enrichment.
+ */
+ async browse(
+ cursor?: string,
+ limit = 20
+ ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
+ // Browse primarily from official registry (has pagination)
+ const result = await this.official.browse(cursor, limit);
+
+ // Optionally enrich with Glama data (best effort, no pagination sync)
+ try {
+ const glamaBrowse = await this.glama.browse(undefined, limit);
+ const enriched = this.enrichOfficialWithGlama(result.servers, glamaBrowse.servers);
+ return { servers: enriched, nextCursor: result.nextCursor };
+ } catch {
+ return result;
+ }
+ }
+
+ /**
+ * Get a single server by ID for secure install flow.
+ * Delegates to appropriate source based on ID prefix.
+ */
+ async getById(registryId: string): Promise {
+ // Glama IDs are prefixed with "glama:"
+ if (registryId.startsWith('glama:')) {
+ logger.warn(`Cannot install Glama-only server: ${registryId}`);
+ return null; // Glama servers can't be auto-installed
+ }
+
+ // Official registry lookup
+ return this.official.getById(registryId);
+ }
+
+ // ── Private ────────────────────────────────────────────────────────────
+
+ /**
+ * Merge Official + Glama, dedup by repository URL.
+ * Official entries take priority.
+ */
+ private mergeAndDeduplicate(
+ official: McpCatalogItem[],
+ glama: McpCatalogItem[]
+ ): McpCatalogItem[] {
+ // Build repo URL index from official entries
+ const officialRepoUrls = new Set();
+ for (const item of official) {
+ if (item.repositoryUrl) {
+ officialRepoUrls.add(normalizeRepoUrl(item.repositoryUrl));
+ }
+ }
+
+ // Enrich official entries with Glama data
+ const enriched = this.enrichOfficialWithGlama(official, glama);
+
+ // Add Glama-only entries (no matching official entry)
+ const glamaOnly = glama.filter((g) => {
+ if (!g.repositoryUrl) return true; // no repo URL = can't match, show separately
+ return !officialRepoUrls.has(normalizeRepoUrl(g.repositoryUrl));
+ });
+
+ return [...enriched, ...glamaOnly];
+ }
+
+ /**
+ * Enrich official entries with Glama metadata (license, tools, glamaUrl).
+ */
+ private enrichOfficialWithGlama(
+ official: McpCatalogItem[],
+ glama: McpCatalogItem[]
+ ): McpCatalogItem[] {
+ // Index Glama by normalized repo URL
+ const glamaByRepo = new Map();
+ for (const g of glama) {
+ if (g.repositoryUrl) {
+ glamaByRepo.set(normalizeRepoUrl(g.repositoryUrl), g);
+ }
+ }
+
+ return official.map((item) => {
+ if (!item.repositoryUrl) return item;
+
+ const glamaMatch = glamaByRepo.get(normalizeRepoUrl(item.repositoryUrl));
+ if (!glamaMatch) return item;
+
+ return {
+ ...item,
+ license: item.license ?? glamaMatch.license,
+ tools: item.tools.length > 0 ? item.tools : glamaMatch.tools,
+ glamaUrl: glamaMatch.glamaUrl,
+ author: item.author ?? glamaMatch.author,
+ hostingType: item.hostingType ?? glamaMatch.hostingType,
+ };
+ });
+ }
+}
diff --git a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts
new file mode 100644
index 00000000..aed9f7a7
--- /dev/null
+++ b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts
@@ -0,0 +1,349 @@
+/**
+ * Fetches and normalizes MCP servers from the Official MCP Registry.
+ *
+ * Base URL: https://registry.modelcontextprotocol.io/v0.1/servers
+ * Cursor-based pagination, no auth required.
+ * Filters for _meta.isLatest to pick only latest versions.
+ */
+
+import https from 'node:https';
+import http from 'node:http';
+
+import { createLogger } from '@shared/utils/logger';
+import type { McpCatalogItem, McpEnvVarDef, McpInstallSpec } from '@shared/types/extensions';
+
+const logger = createLogger('Extensions:OfficialMcpRegistry');
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1/servers';
+const HTTP_TIMEOUT_MS = 15_000;
+const MAX_REDIRECTS = 5;
+const CACHE_TTL_MS = 15 * 60_000; // 15 minutes
+const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
+
+// ── HTTP helper ────────────────────────────────────────────────────────────
+
+function httpGet(
+ url: string,
+ redirectsLeft = MAX_REDIRECTS
+): Promise<{ statusCode: number; body: string }> {
+ return new Promise((resolve, reject) => {
+ const parsedUrl = new URL(url);
+ const transport = parsedUrl.protocol === 'http:' ? http : https;
+ let settled = false;
+
+ const settleResolve = (v: { statusCode: number; body: string }): void => {
+ if (!settled) {
+ settled = true;
+ resolve(v);
+ }
+ };
+ const settleReject = (e: Error): void => {
+ if (!settled) {
+ settled = true;
+ reject(e);
+ }
+ };
+
+ const req = transport.get(url, (res) => {
+ const status = res.statusCode ?? 0;
+ if (status >= 300 && status < 400 && res.headers.location) {
+ if (redirectsLeft <= 0) {
+ res.destroy();
+ settleReject(new Error('Too many redirects'));
+ return;
+ }
+ const redirectUrl = new URL(res.headers.location, url).toString();
+ res.destroy();
+ httpGet(redirectUrl, redirectsLeft - 1).then(settleResolve, settleReject);
+ return;
+ }
+ const chunks: Buffer[] = [];
+ let totalSize = 0;
+ res.on('data', (c: Buffer) => {
+ totalSize += c.length;
+ if (totalSize > MAX_BODY_SIZE) {
+ res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
+ return;
+ }
+ chunks.push(c);
+ });
+ res.on('end', () =>
+ settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
+ );
+ res.on('error', settleReject);
+ });
+ req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`)));
+ req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
+ });
+}
+
+// ── Raw API response shapes ────────────────────────────────────────────────
+
+interface RegistryResponse {
+ servers: RegistryServerEntry[];
+ metadata: { nextCursor?: string; count?: number };
+}
+
+interface RegistryIcon {
+ src: string;
+ mimeType?: string;
+ sizes?: string[];
+ theme?: 'light' | 'dark';
+}
+
+interface RegistryServerEntry {
+ server: {
+ name: string;
+ description?: string;
+ title?: string;
+ version?: string;
+ repository?: { url: string; source?: string };
+ websiteUrl?: string;
+ packages?: RegistryPackage[];
+ remotes?: RegistryRemote[];
+ icons?: RegistryIcon[];
+ };
+ _meta?: {
+ 'io.modelcontextprotocol.registry/official'?: {
+ status?: string;
+ isLatest?: boolean;
+ publishedAt?: string;
+ updatedAt?: string;
+ };
+ };
+}
+
+interface RegistryPackage {
+ registryType: string;
+ identifier: string;
+ version?: string;
+ transport?: { type: string };
+ environmentVariables?: RegistryEnvVar[];
+}
+
+interface RegistryRemote {
+ type: string;
+ url: string;
+ headers?: RegistryHeader[];
+}
+
+interface RegistryHeader {
+ name: string;
+ description?: string;
+ isRequired?: boolean;
+ isSecret?: boolean;
+ value?: string;
+}
+
+interface RegistryEnvVar {
+ name: string;
+ description?: string;
+ isSecret?: boolean;
+ isRequired?: boolean;
+}
+
+// ── Cache ──────────────────────────────────────────────────────────────────
+
+interface SearchCache {
+ key: string;
+ result: McpCatalogItem[];
+ fetchedAt: number;
+}
+
+// ── Service ────────────────────────────────────────────────────────────────
+
+export class OfficialMcpRegistryService {
+ private searchCache: SearchCache | null = null;
+
+ /**
+ * Search the official registry by query text.
+ */
+ async search(query: string, limit = 20): Promise {
+ const cacheKey = `search:${query}:${limit}`;
+ if (
+ this.searchCache?.key === cacheKey &&
+ Date.now() - this.searchCache.fetchedAt < CACHE_TTL_MS
+ ) {
+ return this.searchCache.result;
+ }
+
+ const params = new URLSearchParams({ search: query, limit: String(limit) });
+ const url = `${REGISTRY_BASE_URL}?${params}`;
+
+ try {
+ const resp = await httpGet(url);
+ if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
+ const json = JSON.parse(resp.body) as RegistryResponse;
+ const items = this.normalizeServers(json.servers);
+ this.searchCache = { key: cacheKey, result: items, fetchedAt: Date.now() };
+ return items;
+ } catch (err) {
+ logger.error('Official MCP Registry search failed:', err);
+ return this.searchCache?.result ?? [];
+ }
+ }
+
+ /**
+ * Browse the registry with cursor-based pagination.
+ */
+ async browse(
+ cursor?: string,
+ limit = 20
+ ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
+ const params = new URLSearchParams({ limit: String(limit) });
+ if (cursor) params.set('cursor', cursor);
+ const url = `${REGISTRY_BASE_URL}?${params}`;
+
+ try {
+ const resp = await httpGet(url);
+ if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
+ const json = JSON.parse(resp.body) as RegistryResponse;
+ return {
+ servers: this.normalizeServers(json.servers),
+ nextCursor: json.metadata.nextCursor,
+ };
+ } catch (err) {
+ logger.error('Official MCP Registry browse failed:', err);
+ return { servers: [] };
+ }
+ }
+
+ /**
+ * Get a single server by its registry ID (reverse-DNS name).
+ * Used for secure install flow (main-side re-resolution).
+ */
+ async getById(registryId: string): Promise {
+ // The official registry search API can find by exact name
+ const params = new URLSearchParams({ search: registryId, limit: '5' });
+ const url = `${REGISTRY_BASE_URL}?${params}`;
+
+ try {
+ const resp = await httpGet(url);
+ if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
+ const json = JSON.parse(resp.body) as RegistryResponse;
+ const items = this.normalizeServers(json.servers);
+ return items.find((s) => s.id === registryId) ?? null;
+ } catch (err) {
+ logger.error(`Official MCP Registry getById(${registryId}) failed:`, err);
+ return null;
+ }
+ }
+
+ // ── Private ────────────────────────────────────────────────────────────
+
+ private normalizeServers(entries: RegistryServerEntry[]): McpCatalogItem[] {
+ // Filter to isLatest only (same server name may appear multiple times)
+ const latest = entries.filter((e) => {
+ const meta = e._meta?.['io.modelcontextprotocol.registry/official'];
+ return meta?.isLatest !== false; // include if isLatest is true or undefined
+ });
+
+ // Filter to active only (include servers with no status or status "active")
+ const active = latest.filter((e) => {
+ const meta = e._meta?.['io.modelcontextprotocol.registry/official'];
+ const status = meta?.status;
+ return !status || status === 'active';
+ });
+
+ // Deduplicate by server name (take first = latest version)
+ const seen = new Set();
+ const unique: RegistryServerEntry[] = [];
+ for (const entry of active) {
+ if (!seen.has(entry.server.name)) {
+ seen.add(entry.server.name);
+ unique.push(entry);
+ }
+ }
+
+ return unique.map((entry) => this.normalizeEntry(entry));
+ }
+
+ private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem {
+ const { server } = entry;
+ const meta = entry._meta?.['io.modelcontextprotocol.registry/official'];
+ const installSpec = this.deriveInstallSpec(server);
+ const envVars = this.collectEnvVars(server);
+ const requiresAuth = this.detectAuthRequired(server);
+
+ return {
+ id: server.name,
+ name: server.title ?? server.name.split('/').pop() ?? server.name,
+ description: server.description ?? '',
+ repositoryUrl: server.repository?.url,
+ version: server.version,
+ source: 'official',
+ installSpec,
+ envVars,
+ license: undefined, // Official registry doesn't expose license
+ tools: [], // Tools not included in registry list response
+ glamaUrl: undefined,
+ requiresAuth,
+ iconUrl: this.pickIconUrl(server.icons),
+ websiteUrl: server.websiteUrl,
+ status: meta?.status,
+ publishedAt: meta?.publishedAt,
+ updatedAt: meta?.updatedAt,
+ };
+ }
+
+ private deriveInstallSpec(server: RegistryServerEntry['server']): McpInstallSpec | null {
+ // Prefer npm stdio package
+ const npmPkg = server.packages?.find((p) => p.registryType === 'npm');
+ if (npmPkg) {
+ return {
+ type: 'stdio',
+ npmPackage: npmPkg.identifier,
+ npmVersion: npmPkg.version,
+ };
+ }
+
+ // HTTP/SSE remote
+ const remote = server.remotes?.[0];
+ if (remote) {
+ return {
+ type: 'http',
+ url: remote.url,
+ transportType: remote.type as 'streamable-http' | 'sse' | 'http',
+ };
+ }
+
+ return null;
+ }
+
+ private collectEnvVars(server: RegistryServerEntry['server']): McpEnvVarDef[] {
+ const envVars: McpEnvVarDef[] = [];
+
+ // From packages
+ for (const pkg of server.packages ?? []) {
+ for (const ev of pkg.environmentVariables ?? []) {
+ envVars.push({
+ name: ev.name,
+ isSecret: ev.isSecret ?? false,
+ description: ev.description,
+ isRequired: ev.isRequired,
+ });
+ }
+ }
+
+ return envVars;
+ }
+
+ private detectAuthRequired(server: RegistryServerEntry['server']): boolean {
+ for (const remote of server.remotes ?? []) {
+ for (const header of remote.headers ?? []) {
+ if (header.isRequired) return true;
+ }
+ }
+ return false;
+ }
+
+ /** Pick best icon URL from the registry icons array (prefer dark theme PNG). */
+ private pickIconUrl(icons?: RegistryIcon[]): string | undefined {
+ if (!icons || icons.length === 0) return undefined;
+ // Prefer dark-theme icon, then first available
+ const darkIcon = icons.find((i) => i.theme === 'dark');
+ return (darkIcon ?? icons[0]).src;
+ }
+}
diff --git a/src/main/services/extensions/catalog/PluginCatalogService.ts b/src/main/services/extensions/catalog/PluginCatalogService.ts
new file mode 100644
index 00000000..bc06d6c7
--- /dev/null
+++ b/src/main/services/extensions/catalog/PluginCatalogService.ts
@@ -0,0 +1,318 @@
+/**
+ * Fetches and caches the Claude Code plugin marketplace catalog.
+ *
+ * - Fetches marketplace.json from raw.githubusercontent.com
+ * - ETag + If-None-Match for bandwidth efficiency
+ * - In-memory cache with TTL (15 min)
+ * - Stale cache fallback on network error
+ * - Deduplicates concurrent requests
+ */
+
+import https from 'node:https';
+import http from 'node:http';
+
+import { createLogger } from '@shared/utils/logger';
+import type { PluginCatalogItem } from '@shared/types/extensions';
+import { buildPluginId } from '@shared/utils/extensionNormalizers';
+
+const logger = createLogger('Extensions:PluginCatalog');
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const MARKETPLACE_URL =
+ 'https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json';
+
+const CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes
+const HTTP_TIMEOUT_MS = 15_000; // 15 seconds
+const MAX_REDIRECTS = 5;
+const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
+const MAX_README_CACHE_SIZE = 50; // Max README entries to cache
+
+// ── HTTP helpers (adapted from CliInstallerService) ────────────────────────
+
+interface FetchOptions {
+ headers?: Record;
+}
+
+interface FetchResponse {
+ statusCode: number;
+ headers: Record;
+ body: string;
+}
+
+function httpsGetFollowRedirects(
+ url: string,
+ options: FetchOptions = {},
+ redirectsLeft = MAX_REDIRECTS,
+ timeoutMs = HTTP_TIMEOUT_MS
+): Promise {
+ return new Promise((resolve, reject) => {
+ const parsedUrl = new URL(url);
+ const transport = parsedUrl.protocol === 'http:' ? http : https;
+ let settled = false;
+
+ const settleResolve = (value: FetchResponse): void => {
+ if (settled) return;
+ settled = true;
+ resolve(value);
+ };
+ const settleReject = (err: Error): void => {
+ if (settled) return;
+ settled = true;
+ reject(err);
+ };
+
+ const reqOptions = {
+ headers: options.headers ?? {},
+ };
+
+ const req = transport.get(url, reqOptions, (res) => {
+ const status = res.statusCode ?? 0;
+
+ if (status >= 300 && status < 400 && res.headers.location) {
+ if (redirectsLeft <= 0) {
+ res.destroy();
+ settleReject(new Error('Too many redirects'));
+ return;
+ }
+ const redirectUrl = new URL(res.headers.location, url).toString();
+ res.destroy();
+ httpsGetFollowRedirects(redirectUrl, options, redirectsLeft - 1, timeoutMs).then(
+ settleResolve,
+ settleReject
+ );
+ return;
+ }
+
+ const chunks: Buffer[] = [];
+ let totalSize = 0;
+ res.on('data', (chunk: Buffer) => {
+ totalSize += chunk.length;
+ if (totalSize > MAX_BODY_SIZE) {
+ res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
+ return;
+ }
+ chunks.push(chunk);
+ });
+ res.on('end', () =>
+ settleResolve({
+ statusCode: status,
+ headers: res.headers as Record,
+ body: Buffer.concat(chunks).toString('utf-8'),
+ })
+ );
+ res.on('error', settleReject);
+ });
+
+ req.setTimeout(timeoutMs, () => {
+ req.destroy(new Error(`Connection timed out after ${timeoutMs}ms fetching ${url}`));
+ });
+ req.on('error', (err) => settleReject(err instanceof Error ? err : new Error(String(err))));
+ });
+}
+
+// ── Marketplace JSON shape ─────────────────────────────────────────────────
+
+interface MarketplaceJson {
+ name: string;
+ plugins: RawMarketplacePlugin[];
+}
+
+interface RawMarketplacePlugin {
+ name: string;
+ description?: string;
+ version?: string;
+ category?: string;
+ author?: { name: string; email?: string };
+ source: string | { source: string; url: string; sha?: string };
+ strict?: boolean;
+ lspServers?: Record;
+ mcpServers?: Record;
+ agents?: Record;
+ commands?: Record;
+ hooks?: Record;
+}
+
+// ── Cache ──────────────────────────────────────────────────────────────────
+
+interface CatalogCache {
+ items: PluginCatalogItem[];
+ etag: string | null;
+ fetchedAt: number;
+}
+
+// ── Service ────────────────────────────────────────────────────────────────
+
+export class PluginCatalogService {
+ private cache: CatalogCache | null = null;
+ private fetchInFlight: Promise | null = null;
+ private readmeCache = new Map();
+
+ /**
+ * Get all plugins from the marketplace catalog.
+ * Uses in-memory cache with ETag validation.
+ */
+ async getPlugins(forceRefresh = false): Promise {
+ // Return cached if fresh and not forcing
+ if (!forceRefresh && this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
+ return this.cache.items;
+ }
+
+ // Deduplicate concurrent requests
+ if (this.fetchInFlight) {
+ return this.fetchInFlight;
+ }
+
+ this.fetchInFlight = this.fetchCatalog().finally(() => {
+ this.fetchInFlight = null;
+ });
+
+ return this.fetchInFlight;
+ }
+
+ /**
+ * Get README content for a plugin by its pluginId.
+ * For external plugins (source is URL), fetches README from the GitHub repo.
+ * Returns null for local/bundled plugins or on error.
+ */
+ async getPluginReadme(pluginId: string): Promise {
+ const cached = this.readmeCache.get(pluginId);
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
+ return cached.content;
+ }
+
+ // Need catalog to find the plugin's repo URL
+ const plugins = await this.getPlugins();
+ const plugin = plugins.find((p) => p.pluginId === pluginId);
+ if (!plugin?.homepage) {
+ this.setReadmeCache(pluginId, null);
+ return null;
+ }
+
+ const readmeUrl = this.buildReadmeUrl(plugin.homepage);
+ if (!readmeUrl) {
+ this.setReadmeCache(pluginId, null);
+ return null;
+ }
+
+ try {
+ const response = await httpsGetFollowRedirects(readmeUrl);
+ if (response.statusCode === 200) {
+ this.setReadmeCache(pluginId, response.body);
+ return response.body;
+ }
+ this.setReadmeCache(pluginId, null);
+ return null;
+ } catch (err) {
+ logger.warn(`Failed to fetch README for ${pluginId}:`, err);
+ this.setReadmeCache(pluginId, null);
+ return null;
+ }
+ }
+
+ /**
+ * Look up a single plugin by pluginId from the cached catalog.
+ * Used for main-side re-resolution during install.
+ */
+ async resolvePlugin(pluginId: string): Promise {
+ const plugins = await this.getPlugins();
+ return plugins.find((p) => p.pluginId === pluginId) ?? null;
+ }
+
+ // ── Private ────────────────────────────────────────────────────────────
+
+ /** Set readme cache with LRU eviction */
+ private setReadmeCache(pluginId: string, content: string | null): void {
+ // Evict oldest entries if at capacity
+ if (this.readmeCache.size >= MAX_README_CACHE_SIZE && !this.readmeCache.has(pluginId)) {
+ let oldestKey: string | null = null;
+ let oldestTime = Infinity;
+ for (const [key, entry] of this.readmeCache) {
+ if (entry.fetchedAt < oldestTime) {
+ oldestTime = entry.fetchedAt;
+ oldestKey = key;
+ }
+ }
+ if (oldestKey) this.readmeCache.delete(oldestKey);
+ }
+ this.readmeCache.set(pluginId, { content, fetchedAt: Date.now() });
+ }
+
+ private async fetchCatalog(): Promise {
+ const headers: Record = {};
+ if (this.cache?.etag) {
+ headers['If-None-Match'] = this.cache.etag;
+ }
+
+ try {
+ const response = await httpsGetFollowRedirects(MARKETPLACE_URL, { headers });
+
+ // 304 Not Modified — cache is still valid
+ if (response.statusCode === 304 && this.cache) {
+ this.cache.fetchedAt = Date.now();
+ logger.info('Marketplace catalog not modified (304)');
+ return this.cache.items;
+ }
+
+ if (response.statusCode !== 200) {
+ throw new Error(`HTTP ${response.statusCode} fetching marketplace`);
+ }
+
+ const json = JSON.parse(response.body) as MarketplaceJson;
+ const items = this.parseMarketplace(json);
+ const etag = (response.headers['etag'] as string) ?? null;
+
+ this.cache = { items, etag, fetchedAt: Date.now() };
+ logger.info(`Fetched ${items.length} plugins from marketplace "${json.name}"`);
+ return items;
+ } catch (err) {
+ // Stale cache fallback
+ if (this.cache) {
+ logger.warn('Marketplace fetch failed, using stale cache:', err);
+ return this.cache.items;
+ }
+ logger.error('Marketplace fetch failed with no cache:', err);
+ throw err;
+ }
+ }
+
+ private parseMarketplace(json: MarketplaceJson): PluginCatalogItem[] {
+ const marketplaceName = json.name;
+
+ return json.plugins.map((raw): PluginCatalogItem => {
+ const qualifiedName = buildPluginId(raw.name, marketplaceName);
+ const isExternal = typeof raw.source === 'object';
+ const homepage = isExternal ? (raw.source as { url: string }).url : undefined;
+
+ return {
+ pluginId: qualifiedName,
+ marketplaceId: qualifiedName,
+ qualifiedName,
+ name: raw.name,
+ description: raw.description ?? '',
+ category: raw.category ?? 'other',
+ author: raw.author,
+ version: raw.version,
+ homepage: homepage?.replace(/\.git$/, ''),
+ tags: undefined,
+ hasLspServers: raw.lspServers != null && Object.keys(raw.lspServers).length > 0,
+ hasMcpServers: raw.mcpServers != null && Object.keys(raw.mcpServers).length > 0,
+ hasAgents: raw.agents != null && Object.keys(raw.agents).length > 0,
+ hasCommands: raw.commands != null && Object.keys(raw.commands).length > 0,
+ hasHooks: raw.hooks != null && Object.keys(raw.hooks).length > 0,
+ isExternal,
+ };
+ });
+ }
+
+ /**
+ * Build raw GitHub README URL from a GitHub repo URL.
+ * e.g. https://github.com/org/repo → https://raw.githubusercontent.com/org/repo/main/README.md
+ */
+ private buildReadmeUrl(repoUrl: string): string | null {
+ const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
+ if (!match) return null;
+ const [, owner, repo] = match;
+ return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
+ }
+}
diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts
new file mode 100644
index 00000000..9e2517cb
--- /dev/null
+++ b/src/main/services/extensions/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Extension services barrel export.
+ */
+
+export { PluginCatalogService } from './catalog/PluginCatalogService';
+export { OfficialMcpRegistryService } from './catalog/OfficialMcpRegistryService';
+export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
+export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
+export { PluginInstallationStateService } from './state/PluginInstallationStateService';
+export { McpInstallationStateService } from './state/McpInstallationStateService';
+export { ExtensionFacadeService } from './ExtensionFacadeService';
+export { PluginInstallService } from './install/PluginInstallService';
+export { McpInstallService } from './install/McpInstallService';
+export { ApiKeyService } from './apikeys/ApiKeyService';
+export { GitHubStarsService } from './catalog/GitHubStarsService';
diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts
new file mode 100644
index 00000000..0cb8cf46
--- /dev/null
+++ b/src/main/services/extensions/install/McpInstallService.ts
@@ -0,0 +1,347 @@
+/**
+ * McpInstallService — installs/uninstalls MCP servers via Claude CLI.
+ *
+ * Security model: renderer sends ONLY registryId + user inputs (env values,
+ * headers, server name). Main re-fetches server spec from registry via getById()
+ * and builds CLI args from the fresh registry data (never trusts install spec
+ * from renderer).
+ */
+
+import { execCli } from '@main/utils/childProcess';
+import { createLogger } from '@shared/utils/logger';
+
+import type {
+ McpCustomInstallRequest,
+ McpInstallRequest,
+ OperationResult,
+} from '@shared/types/extensions';
+import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
+
+const logger = createLogger('Extensions:McpInstall');
+
+/** Validate server name: alphanumeric, dashes, underscores, dots */
+const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
+
+/** Allowed scope values (prevent command injection) */
+const VALID_SCOPES = new Set(['local', 'user', 'project']);
+
+/** Env var key must be safe shell identifier */
+const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
+
+/** HTTP header key must be safe (RFC 7230 token) */
+const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
+
+const TIMEOUT_MS = 30_000;
+
+export class McpInstallService {
+ constructor(
+ private readonly claudeBinary: string | null,
+ private readonly aggregator: McpCatalogAggregator
+ ) {}
+
+ async install(request: McpInstallRequest): Promise {
+ const { registryId, serverName, scope, projectPath, envValues, headers } = request;
+
+ // 1. Validate server name
+ if (!SERVER_NAME_RE.test(serverName)) {
+ return {
+ state: 'error',
+ error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`,
+ };
+ }
+
+ // 2. Validate scope
+ if (scope && !VALID_SCOPES.has(scope)) {
+ return {
+ state: 'error',
+ error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
+ };
+ }
+
+ // 3. Validate env var keys (prevent command injection)
+ for (const key of Object.keys(envValues)) {
+ if (!ENV_KEY_RE.test(key)) {
+ return {
+ state: 'error',
+ error: `Invalid environment variable name: "${key}". Use uppercase alphanumeric and underscores.`,
+ };
+ }
+ }
+
+ // 4. Validate header keys (prevent header injection)
+ for (const header of headers) {
+ if (header.key && !HEADER_KEY_RE.test(header.key)) {
+ return {
+ state: 'error',
+ error: `Invalid header name: "${header.key}". Use alphanumeric, dashes, underscores.`,
+ };
+ }
+ }
+
+ // 5. Validate projectPath (if provided, must be absolute)
+ if (projectPath && !projectPath.startsWith('/')) {
+ return {
+ state: 'error',
+ error: 'projectPath must be an absolute path',
+ };
+ }
+
+ // 6. Re-fetch from registry (don't trust renderer-provided install spec)
+ const server = await this.aggregator.getById(registryId);
+ if (!server) {
+ return {
+ state: 'error',
+ error: `MCP server "${registryId}" not found in registry`,
+ };
+ }
+
+ if (!server.installSpec) {
+ return {
+ state: 'error',
+ error: `MCP server "${server.name}" does not have an automatic install spec. Manual setup required.`,
+ };
+ }
+
+ // 7. Build CLI args based on install spec type
+ const args: string[] = ['mcp', 'add'];
+
+ // Scope flag (-s)
+ if (scope && scope !== 'local') {
+ args.push('-s', scope);
+ }
+
+ if (server.installSpec.type === 'stdio') {
+ // Stdio: claude mcp add [-s scope] [-e KEY=val...] -- npx -y [@version]
+ // Add env flags
+ for (const [key, value] of Object.entries(envValues)) {
+ if (key && value) {
+ args.push('-e', `${key}=${value}`);
+ }
+ }
+
+ args.push(serverName);
+ args.push('--');
+ args.push('npx', '-y');
+
+ const pkg = server.installSpec.npmVersion
+ ? `${server.installSpec.npmPackage}@${server.installSpec.npmVersion}`
+ : server.installSpec.npmPackage;
+ args.push(pkg);
+ } else if (server.installSpec.type === 'http') {
+ // HTTP/SSE: claude mcp add [-s scope] -t [-H "Key: val"...]
+ const transport = server.installSpec.transportType === 'sse' ? 'sse' : 'http';
+ args.push('-t', transport);
+
+ // Add header flags
+ for (const header of headers) {
+ if (header.key && header.value) {
+ args.push('-H', `${header.key}: ${header.value}`);
+ }
+ }
+
+ // Add env flags (some HTTP servers also need env vars)
+ for (const [key, value] of Object.entries(envValues)) {
+ if (key && value) {
+ args.push('-e', `${key}=${value}`);
+ }
+ }
+
+ args.push(serverName);
+ args.push(server.installSpec.url);
+ } else {
+ return {
+ state: 'error',
+ error: `Unsupported install spec type: ${(server.installSpec as { type: string }).type}`,
+ };
+ }
+
+ logger.info(
+ `Installing MCP server: ${serverName} (type: ${server.installSpec.type}, scope: ${scope ?? 'local'})`
+ );
+ // Don't log env values or header values (may contain secrets)
+
+ try {
+ const { stderr } = await execCli(this.claudeBinary, args, {
+ timeout: TIMEOUT_MS,
+ cwd: projectPath,
+ });
+
+ if (stderr) {
+ logger.warn(`MCP install stderr: ${stderr.slice(0, 200)}`);
+ }
+
+ return { state: 'success' };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ // Mask potential secrets in error output
+ const safeMessage = maskSecrets(
+ message,
+ envValues,
+ headers.map((h) => h.value)
+ );
+ logger.error(`MCP install failed: ${safeMessage}`);
+ return { state: 'error', error: safeMessage };
+ }
+ }
+
+ /**
+ * Install a custom MCP server — user provides installSpec directly (bypasses registry).
+ */
+ async installCustom(request: McpCustomInstallRequest): Promise {
+ const { serverName, scope, projectPath, installSpec, envValues, headers } = request;
+
+ // Validate inputs (same rules as registry install)
+ if (!SERVER_NAME_RE.test(serverName)) {
+ return {
+ state: 'error',
+ error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`,
+ };
+ }
+
+ if (scope && !VALID_SCOPES.has(scope)) {
+ return { state: 'error', error: `Invalid scope: "${scope}".` };
+ }
+
+ for (const key of Object.keys(envValues)) {
+ if (!ENV_KEY_RE.test(key)) {
+ return { state: 'error', error: `Invalid env var name: "${key}".` };
+ }
+ }
+
+ for (const header of headers) {
+ if (header.key && !HEADER_KEY_RE.test(header.key)) {
+ return { state: 'error', error: `Invalid header name: "${header.key}".` };
+ }
+ }
+
+ if (projectPath && !projectPath.startsWith('/')) {
+ return { state: 'error', error: 'projectPath must be an absolute path' };
+ }
+
+ // Build CLI args from provided installSpec
+ const args: string[] = ['mcp', 'add'];
+
+ if (scope && scope !== 'local') {
+ args.push('-s', scope);
+ }
+
+ if (installSpec.type === 'stdio') {
+ for (const [key, value] of Object.entries(envValues)) {
+ if (key && value) args.push('-e', `${key}=${value}`);
+ }
+
+ args.push(serverName);
+ args.push('--');
+ args.push('npx', '-y');
+
+ const pkg = installSpec.npmVersion
+ ? `${installSpec.npmPackage}@${installSpec.npmVersion}`
+ : installSpec.npmPackage;
+ args.push(pkg);
+ } else if (installSpec.type === 'http') {
+ const transport = installSpec.transportType === 'sse' ? 'sse' : 'http';
+ args.push('-t', transport);
+
+ for (const header of headers) {
+ if (header.key && header.value) {
+ args.push('-H', `${header.key}: ${header.value}`);
+ }
+ }
+
+ for (const [key, value] of Object.entries(envValues)) {
+ if (key && value) args.push('-e', `${key}=${value}`);
+ }
+
+ args.push(serverName);
+ args.push(installSpec.url);
+ } else {
+ return { state: 'error', error: 'Unsupported install spec type' };
+ }
+
+ logger.info(
+ `Installing custom MCP server: ${serverName} (type: ${installSpec.type}, scope: ${scope ?? 'local'})`
+ );
+
+ try {
+ const { stderr } = await execCli(this.claudeBinary, args, {
+ timeout: TIMEOUT_MS,
+ cwd: projectPath,
+ });
+
+ if (stderr) {
+ logger.warn(`Custom MCP install stderr: ${stderr.slice(0, 200)}`);
+ }
+
+ return { state: 'success' };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const safeMessage = maskSecrets(
+ message,
+ envValues,
+ headers.map((h) => h.value)
+ );
+ logger.error(`Custom MCP install failed: ${safeMessage}`);
+ return { state: 'error', error: safeMessage };
+ }
+ }
+
+ async uninstall(name: string, scope?: string, projectPath?: string): Promise {
+ if (!SERVER_NAME_RE.test(name)) {
+ return {
+ state: 'error',
+ error: `Invalid server name: "${name}"`,
+ };
+ }
+
+ if (scope && !VALID_SCOPES.has(scope)) {
+ return {
+ state: 'error',
+ error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
+ };
+ }
+
+ if (projectPath && !projectPath.startsWith('/')) {
+ return {
+ state: 'error',
+ error: 'projectPath must be an absolute path',
+ };
+ }
+
+ const args = ['mcp', 'remove'];
+ if (scope && scope !== 'local') {
+ args.push('-s', scope);
+ }
+ args.push(name);
+
+ logger.info(`Removing MCP server: ${name} (scope: ${scope ?? 'local'})`);
+
+ try {
+ await execCli(this.claudeBinary, args, {
+ timeout: TIMEOUT_MS,
+ cwd: projectPath,
+ });
+ return { state: 'success' };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ logger.error(`MCP uninstall failed: ${message}`);
+ return { state: 'error', error: message };
+ }
+ }
+}
+
+/** Replace secret values in error messages with [REDACTED] */
+function maskSecrets(
+ message: string,
+ envValues: Record,
+ headerValues: string[]
+): string {
+ let result = message;
+ const secrets = [
+ ...Object.values(envValues).filter((v) => v.length > 3),
+ ...headerValues.filter((v) => v.length > 3),
+ ];
+ for (const secret of secrets) {
+ result = result.replaceAll(secret, '[REDACTED]');
+ }
+ return result;
+}
diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts
new file mode 100644
index 00000000..ac4a76b5
--- /dev/null
+++ b/src/main/services/extensions/install/PluginInstallService.ts
@@ -0,0 +1,154 @@
+/**
+ * PluginInstallService — installs/uninstalls plugins via Claude CLI.
+ *
+ * Security model: renderer sends ONLY pluginId, main resolves qualifiedName
+ * from the current catalog snapshot (never trusts renderer-provided paths).
+ */
+
+import { execCli } from '@main/utils/childProcess';
+import { createLogger } from '@shared/utils/logger';
+
+import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
+import type { PluginCatalogService } from '../catalog/PluginCatalogService';
+
+const logger = createLogger('Extensions:PluginInstall');
+
+/** Validate qualifiedName: must be @ with safe characters */
+const QUALIFIED_NAME_RE = /^[\w.-]+@[\w.-]+$/;
+
+/** Allowed scope values (prevent command injection) */
+const VALID_SCOPES = new Set(['local', 'user', 'project']);
+
+const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
+const UNINSTALL_TIMEOUT_MS = 30_000;
+
+export class PluginInstallService {
+ constructor(
+ private readonly claudeBinary: string | null,
+ private readonly catalogService: PluginCatalogService
+ ) {}
+
+ async install(request: PluginInstallRequest): Promise {
+ const { pluginId, scope, projectPath } = request;
+
+ // 1. Validate scope
+ if (scope && !VALID_SCOPES.has(scope)) {
+ return {
+ state: 'error',
+ error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
+ };
+ }
+
+ // 2. Validate projectPath
+ if (projectPath && !projectPath.startsWith('/')) {
+ return {
+ state: 'error',
+ error: 'projectPath must be an absolute path',
+ };
+ }
+
+ // 3. Resolve qualifiedName from catalog (NOT from renderer)
+ const resolved = await this.catalogService.resolvePlugin(pluginId);
+ if (!resolved) {
+ return {
+ state: 'error',
+ error: `Plugin "${pluginId}" not found in catalog`,
+ };
+ }
+
+ const { qualifiedName } = resolved;
+
+ // 2. Validate qualifiedName format (prevent injection)
+ if (!QUALIFIED_NAME_RE.test(qualifiedName)) {
+ return {
+ state: 'error',
+ error: `Invalid plugin identifier: ${qualifiedName}`,
+ };
+ }
+
+ // 5. Build CLI args: claude plugin install [-s scope]
+ const args = ['plugin', 'install'];
+ if (scope && scope !== 'user') {
+ args.push('-s', scope);
+ }
+ args.push(qualifiedName);
+
+ logger.info(`Installing plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`);
+
+ try {
+ const { stdout, stderr } = await execCli(this.claudeBinary, args, {
+ timeout: INSTALL_TIMEOUT_MS,
+ cwd: projectPath,
+ });
+
+ if (stderr && !stdout) {
+ logger.warn(`Plugin install stderr: ${stderr}`);
+ }
+
+ return { state: 'success' };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ logger.error(`Plugin install failed: ${message}`);
+ return { state: 'error', error: message };
+ }
+ }
+
+ async uninstall(
+ pluginId: string,
+ scope?: string,
+ projectPath?: string
+ ): Promise {
+ // Validate scope
+ if (scope && !VALID_SCOPES.has(scope)) {
+ return {
+ state: 'error',
+ error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
+ };
+ }
+
+ if (projectPath && !projectPath.startsWith('/')) {
+ return {
+ state: 'error',
+ error: 'projectPath must be an absolute path',
+ };
+ }
+
+ // Resolve qualifiedName from catalog
+ const resolved = await this.catalogService.resolvePlugin(pluginId);
+ if (!resolved) {
+ return {
+ state: 'error',
+ error: `Plugin "${pluginId}" not found in catalog`,
+ };
+ }
+
+ const { qualifiedName } = resolved;
+
+ if (!QUALIFIED_NAME_RE.test(qualifiedName)) {
+ return {
+ state: 'error',
+ error: `Invalid plugin identifier: ${qualifiedName}`,
+ };
+ }
+
+ const args = ['plugin', 'uninstall'];
+ if (scope && scope !== 'user') {
+ args.push('-s', scope);
+ }
+ args.push(qualifiedName);
+
+ logger.info(`Uninstalling plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`);
+
+ try {
+ await execCli(this.claudeBinary, args, {
+ timeout: UNINSTALL_TIMEOUT_MS,
+ cwd: projectPath,
+ });
+ return { state: 'success' };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ logger.error(`Plugin uninstall failed: ${message}`);
+ return { state: 'error', error: message };
+ }
+ }
+}
diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts
new file mode 100644
index 00000000..4b75daaf
--- /dev/null
+++ b/src/main/services/extensions/state/McpInstallationStateService.ts
@@ -0,0 +1,105 @@
+/**
+ * Reads installed MCP server state from the filesystem.
+ *
+ * Sources:
+ * - User scope: ~/.claude.json → mcpServers
+ * - Project scope: .mcp.json in project root
+ * - Local scope: determined by Claude CLI (may also be in ~/.claude.json)
+ *
+ * Both files are managed by the Claude CLI. This service is read-only.
+ */
+
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+
+import { createLogger } from '@shared/utils/logger';
+import type { InstalledMcpEntry } from '@shared/types/extensions';
+import { getHomeDir } from '@main/utils/pathDecoder';
+
+const logger = createLogger('Extensions:McpState');
+
+const CACHE_TTL_MS = 10_000; // 10 seconds
+
+interface TimedCache {
+ data: T;
+ fetchedAt: number;
+}
+
+export class McpInstallationStateService {
+ private cache: TimedCache | null = null;
+
+ /**
+ * Get all installed MCP servers across user and project scopes.
+ */
+ async getInstalled(projectPath?: string): Promise {
+ // Cache is project-path-dependent, so invalidate on path change
+ if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
+ return this.cache.data;
+ }
+
+ const entries: InstalledMcpEntry[] = [];
+
+ // User scope: ~/.claude.json
+ const userEntries = await this.readUserMcpServers();
+ entries.push(...userEntries);
+
+ // Project scope: .mcp.json
+ if (projectPath) {
+ const projectEntries = await this.readProjectMcpServers(projectPath);
+ entries.push(...projectEntries);
+ }
+
+ this.cache = { data: entries, fetchedAt: Date.now() };
+ return entries;
+ }
+
+ /**
+ * Invalidate cache. Call after install/uninstall operations.
+ */
+ invalidateCache(): void {
+ this.cache = null;
+ }
+
+ // ── Private ────────────────────────────────────────────────────────────
+
+ private async readUserMcpServers(): Promise {
+ const configPath = path.join(getHomeDir(), '.claude.json');
+ return this.readMcpServersFromFile(configPath, 'user');
+ }
+
+ private async readProjectMcpServers(projectPath: string): Promise {
+ const configPath = path.join(projectPath, '.mcp.json');
+ return this.readMcpServersFromFile(configPath, 'project');
+ }
+
+ private async readMcpServersFromFile(
+ filePath: string,
+ scope: 'user' | 'project'
+ ): Promise {
+ try {
+ const raw = await fs.readFile(filePath, 'utf-8');
+ const json = JSON.parse(raw) as Record;
+ const mcpServers = json.mcpServers as
+ | Record
+ | undefined;
+
+ if (!mcpServers || typeof mcpServers !== 'object') {
+ return [];
+ }
+
+ return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
+ let transport: string | undefined;
+ if (config.command) transport = 'stdio';
+ else if (config.url) transport = 'http';
+
+ return { name, scope, transport };
+ });
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+ return [];
+ }
+ logger.error(`Failed to read MCP servers from ${filePath}:`, err);
+ return [];
+ }
+ }
+}
diff --git a/src/main/services/extensions/state/PluginInstallationStateService.ts b/src/main/services/extensions/state/PluginInstallationStateService.ts
new file mode 100644
index 00000000..08954eb0
--- /dev/null
+++ b/src/main/services/extensions/state/PluginInstallationStateService.ts
@@ -0,0 +1,178 @@
+/**
+ * Reads plugin installed state and install counts from the filesystem.
+ *
+ * Sources:
+ * - Installed state: ~/.claude/plugins/installed_plugins.json
+ * - Install counts: ~/.claude/plugins/install-counts-cache.json
+ *
+ * Both files are managed by the Claude CLI. This service is read-only.
+ */
+
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+
+import { createLogger } from '@shared/utils/logger';
+import type { InstalledPluginEntry } from '@shared/types/extensions';
+import type { InstallScope } from '@shared/types/extensions';
+import { getClaudeBasePath } from '@main/utils/pathDecoder';
+
+const logger = createLogger('Extensions:PluginState');
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const INSTALLED_STATE_TTL_MS = 10_000; // 10 seconds
+const INSTALL_COUNTS_TTL_MS = 5 * 60_000; // 5 minutes
+
+// ── Raw file shapes ────────────────────────────────────────────────────────
+
+interface InstalledPluginsJson {
+ version: number;
+ plugins: Record<
+ string, // qualifiedName
+ Array<{
+ scope: string;
+ installPath?: string;
+ version?: string;
+ installedAt?: string;
+ lastUpdated?: string;
+ gitCommitSha?: string;
+ }>
+ >;
+}
+
+interface InstallCountsJson {
+ version: number;
+ fetchedAt: string;
+ counts: Array<{
+ plugin: string; // qualifiedName format
+ unique_installs: number;
+ }>;
+}
+
+// ── Cache ──────────────────────────────────────────────────────────────────
+
+interface TimedCache {
+ data: T;
+ fetchedAt: number;
+}
+
+// ── Service ────────────────────────────────────────────────────────────────
+
+export class PluginInstallationStateService {
+ private installedCache: TimedCache | null = null;
+ private countsCache: TimedCache> | null = null;
+
+ /**
+ * Get all installed plugins across all scopes.
+ * Returns merged list from installed_plugins.json with scope tags.
+ */
+ async getInstalledPlugins(_projectPath?: string): Promise {
+ if (
+ this.installedCache &&
+ Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS
+ ) {
+ return this.installedCache.data;
+ }
+
+ const entries = await this.readInstalledPlugins();
+ this.installedCache = { data: entries, fetchedAt: Date.now() };
+ return entries;
+ }
+
+ /**
+ * Get install counts keyed by pluginId (qualifiedName).
+ */
+ async getInstallCounts(): Promise> {
+ if (this.countsCache && Date.now() - this.countsCache.fetchedAt < INSTALL_COUNTS_TTL_MS) {
+ return this.countsCache.data;
+ }
+
+ const counts = await this.readInstallCounts();
+ this.countsCache = { data: counts, fetchedAt: Date.now() };
+ return counts;
+ }
+
+ /**
+ * Invalidate all caches. Call after install/uninstall operations.
+ */
+ invalidateCache(): void {
+ this.installedCache = null;
+ this.countsCache = null;
+ }
+
+ // ── Private ────────────────────────────────────────────────────────────
+
+ private getPluginsDir(): string {
+ return path.join(getClaudeBasePath(), 'plugins');
+ }
+
+ private async readInstalledPlugins(): Promise {
+ const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
+
+ try {
+ const raw = await fs.readFile(filePath, 'utf-8');
+ const json = JSON.parse(raw) as InstalledPluginsJson;
+
+ if (json.version !== 2 || !json.plugins) {
+ logger.warn(`Unexpected installed_plugins.json version: ${json.version}`);
+ return [];
+ }
+
+ const entries: InstalledPluginEntry[] = [];
+
+ for (const [qualifiedName, installations] of Object.entries(json.plugins)) {
+ for (const inst of installations) {
+ entries.push({
+ pluginId: qualifiedName,
+ scope: this.normalizeScope(inst.scope),
+ version: inst.version,
+ installedAt: inst.installedAt,
+ installPath: inst.installPath,
+ });
+ }
+ }
+
+ return entries;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+ return []; // No plugins installed yet
+ }
+ logger.error('Failed to read installed_plugins.json:', err);
+ return [];
+ }
+ }
+
+ private async readInstallCounts(): Promise> {
+ const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');
+
+ try {
+ const raw = await fs.readFile(filePath, 'utf-8');
+ const json = JSON.parse(raw) as InstallCountsJson;
+
+ const map = new Map();
+
+ if (json.counts && Array.isArray(json.counts)) {
+ for (const entry of json.counts) {
+ // Install counts use qualifiedName format (name@marketplace)
+ map.set(entry.plugin, entry.unique_installs);
+ }
+ }
+
+ return map;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+ return new Map();
+ }
+ logger.error('Failed to read install-counts-cache.json:', err);
+ return new Map();
+ }
+ }
+
+ private normalizeScope(raw: string): InstallScope {
+ const lower = raw.toLowerCase();
+ if (lower === 'user' || lower === 'project' || lower === 'local') {
+ return lower;
+ }
+ return 'user'; // safe default
+ }
+}
diff --git a/src/main/services/index.ts b/src/main/services/index.ts
index 08a5642a..23d40919 100644
--- a/src/main/services/index.ts
+++ b/src/main/services/index.ts
@@ -15,3 +15,5 @@ export * from './error';
export * from './infrastructure';
export * from './parsing';
export * from './team';
+export * from './schedule';
+export * from './extensions';
diff --git a/src/main/services/schedule/JsonScheduleRepository.ts b/src/main/services/schedule/JsonScheduleRepository.ts
new file mode 100644
index 00000000..bbb8461c
--- /dev/null
+++ b/src/main/services/schedule/JsonScheduleRepository.ts
@@ -0,0 +1,206 @@
+/**
+ * JSON-based ScheduleRepository implementation.
+ *
+ * Storage layout:
+ * {getSchedulesBasePath()}/
+ * schedules.json — Schedule[]
+ * runs/{scheduleId}.json — ScheduleRun[] (newest first, max 50)
+ * logs/{scheduleId}/{runId}.log — stdout (max 64KB)
+ * logs/{scheduleId}/{runId}.err — stderr (max 16KB)
+ */
+
+import { atomicWriteAsync } from '@main/utils/atomicWrite';
+import { getSchedulesBasePath } from '@main/utils/pathDecoder';
+import { createLogger } from '@shared/utils/logger';
+import * as fs from 'fs';
+import * as path from 'path';
+
+import type { Schedule, ScheduleRun } from '@shared/types';
+import type { ScheduleRepository } from './ScheduleRepository';
+
+const logger = createLogger('Service:JsonScheduleRepo');
+
+const READ_TIMEOUT_MS = 5_000;
+const MAX_RUNS_PER_SCHEDULE = 50;
+
+export class JsonScheduleRepository implements ScheduleRepository {
+ private get basePath(): string {
+ return getSchedulesBasePath();
+ }
+
+ private get schedulesFilePath(): string {
+ return path.join(this.basePath, 'schedules.json');
+ }
+
+ private runsFilePath(scheduleId: string): string {
+ return path.join(this.basePath, 'runs', `${scheduleId}.json`);
+ }
+
+ private logsDir(scheduleId: string): string {
+ return path.join(this.basePath, 'logs', scheduleId);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Schedule CRUD
+ // ---------------------------------------------------------------------------
+
+ async listSchedules(): Promise {
+ return this.readSchedulesFile();
+ }
+
+ async getSchedule(id: string): Promise {
+ const schedules = await this.readSchedulesFile();
+ return schedules.find((s) => s.id === id) ?? null;
+ }
+
+ async getSchedulesByTeam(teamName: string): Promise {
+ const schedules = await this.readSchedulesFile();
+ return schedules.filter((s) => s.teamName === teamName);
+ }
+
+ async saveSchedule(schedule: Schedule): Promise {
+ const schedules = await this.readSchedulesFile();
+ const idx = schedules.findIndex((s) => s.id === schedule.id);
+ if (idx >= 0) {
+ schedules[idx] = schedule;
+ } else {
+ schedules.push(schedule);
+ }
+ await this.writeSchedulesFile(schedules);
+ }
+
+ async deleteSchedule(id: string): Promise {
+ const schedules = await this.readSchedulesFile();
+ const filtered = schedules.filter((s) => s.id !== id);
+ if (filtered.length !== schedules.length) {
+ await this.writeSchedulesFile(filtered);
+ }
+ // Clean up runs and logs
+ const runsFile = this.runsFilePath(id);
+ await fs.promises.unlink(runsFile).catch(() => undefined);
+ const logsPath = this.logsDir(id);
+ await fs.promises.rm(logsPath, { recursive: true, force: true }).catch(() => undefined);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Run CRUD
+ // ---------------------------------------------------------------------------
+
+ async listRuns(
+ scheduleId: string,
+ opts?: { limit?: number; offset?: number }
+ ): Promise {
+ const runs = await this.readRunsFile(scheduleId);
+ const offset = opts?.offset ?? 0;
+ const limit = opts?.limit ?? runs.length;
+ return runs.slice(offset, offset + limit);
+ }
+
+ async getLatestRun(scheduleId: string): Promise {
+ const runs = await this.readRunsFile(scheduleId);
+ return runs[0] ?? null;
+ }
+
+ async saveRun(run: ScheduleRun): Promise {
+ const runs = await this.readRunsFile(run.scheduleId);
+ const idx = runs.findIndex((r) => r.id === run.id);
+ if (idx >= 0) {
+ runs[idx] = run;
+ } else {
+ runs.unshift(run); // newest first
+ }
+ // Enforce max limit
+ const trimmed = runs.slice(0, MAX_RUNS_PER_SCHEDULE);
+ await this.writeRunsFile(run.scheduleId, trimmed);
+ }
+
+ async pruneOldRuns(scheduleId: string, keepCount: number): Promise {
+ const runs = await this.readRunsFile(scheduleId);
+ if (runs.length <= keepCount) {
+ return 0;
+ }
+ const removed = runs.slice(keepCount);
+ const kept = runs.slice(0, keepCount);
+ await this.writeRunsFile(scheduleId, kept);
+
+ // Clean up log files for pruned runs
+ for (const run of removed) {
+ await this.deleteRunLogs(scheduleId, run.id);
+ }
+
+ return removed.length;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Internal I/O
+ // ---------------------------------------------------------------------------
+
+ private async readSchedulesFile(): Promise {
+ return this.readJsonFile(this.schedulesFilePath, []);
+ }
+
+ private async writeSchedulesFile(schedules: Schedule[]): Promise {
+ await atomicWriteAsync(this.schedulesFilePath, JSON.stringify(schedules, null, 2));
+ }
+
+ private async readRunsFile(scheduleId: string): Promise {
+ return this.readJsonFile(this.runsFilePath(scheduleId), []);
+ }
+
+ private async writeRunsFile(scheduleId: string, runs: ScheduleRun[]): Promise {
+ await atomicWriteAsync(this.runsFilePath(scheduleId), JSON.stringify(runs, null, 2));
+ }
+
+ private async readJsonFile(filePath: string, defaultValue: T): Promise {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
+ try {
+ const content = await fs.promises.readFile(filePath, {
+ encoding: 'utf8',
+ signal: controller.signal,
+ });
+ return JSON.parse(content) as T;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return defaultValue;
+ }
+ logger.warn(
+ `Failed to read ${filePath}: ${error instanceof Error ? error.message : String(error)}`
+ );
+ return defaultValue;
+ }
+ }
+
+ private async deleteRunLogs(scheduleId: string, runId: string): Promise {
+ const dir = this.logsDir(scheduleId);
+ await fs.promises.unlink(path.join(dir, `${runId}.log`)).catch(() => undefined);
+ await fs.promises.unlink(path.join(dir, `${runId}.err`)).catch(() => undefined);
+ }
+
+ async saveRunLogs(
+ scheduleId: string,
+ runId: string,
+ stdout: string,
+ stderr: string
+ ): Promise {
+ const dir = this.logsDir(scheduleId);
+ await fs.promises.mkdir(dir, { recursive: true });
+ await Promise.all([
+ fs.promises.writeFile(path.join(dir, `${runId}.log`), stdout, 'utf8'),
+ fs.promises.writeFile(path.join(dir, `${runId}.err`), stderr, 'utf8'),
+ ]);
+ }
+
+ async getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }> {
+ const dir = this.logsDir(scheduleId);
+ const [stdout, stderr] = await Promise.all([
+ fs.promises.readFile(path.join(dir, `${runId}.log`), 'utf8').catch(() => ''),
+ fs.promises.readFile(path.join(dir, `${runId}.err`), 'utf8').catch(() => ''),
+ ]);
+ return { stdout, stderr };
+ }
+}
diff --git a/src/main/services/schedule/ScheduleRepository.ts b/src/main/services/schedule/ScheduleRepository.ts
new file mode 100644
index 00000000..aebc14c7
--- /dev/null
+++ b/src/main/services/schedule/ScheduleRepository.ts
@@ -0,0 +1,24 @@
+/**
+ * Schedule repository interface — abstracts storage backend.
+ *
+ * Current implementation: JsonScheduleRepository (JSON files on disk).
+ * Future upgrade path: Drizzle + sql.js (WASM, no native modules).
+ */
+
+import type { Schedule, ScheduleRun } from '@shared/types';
+
+export interface ScheduleRepository {
+ listSchedules(): Promise;
+ getSchedule(id: string): Promise;
+ getSchedulesByTeam(teamName: string): Promise;
+ saveSchedule(schedule: Schedule): Promise;
+ deleteSchedule(id: string): Promise;
+
+ listRuns(scheduleId: string, opts?: { limit?: number; offset?: number }): Promise;
+ getLatestRun(scheduleId: string): Promise;
+ saveRun(run: ScheduleRun): Promise;
+ pruneOldRuns(scheduleId: string, keepCount: number): Promise;
+
+ saveRunLogs(scheduleId: string, runId: string, stdout: string, stderr: string): Promise;
+ getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }>;
+}
diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts
new file mode 100644
index 00000000..6dab6fd8
--- /dev/null
+++ b/src/main/services/schedule/ScheduledTaskExecutor.ts
@@ -0,0 +1,224 @@
+/**
+ * One-shot executor for scheduled tasks.
+ *
+ * Spawns `claude -p ` as a child process with stream-json output,
+ * captures stdout/stderr, and returns the result when the process exits.
+ *
+ * Uses `--output-format stream-json` so the renderer can display rich logs
+ * (thinking blocks, tool cards, markdown) via CliLogsRichView.
+ */
+
+import { killProcessTree, spawnCli } from '@main/utils/childProcess';
+import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
+import { createLogger } from '@shared/utils/logger';
+
+import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
+
+import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
+import type { ChildProcess } from 'child_process';
+
+const logger = createLogger('Service:ScheduledTaskExecutor');
+
+const STDOUT_MAX_BYTES = 512 * 1024; // 512KB — stream-json is verbose (JSON wrappers, thinking, tool_use)
+const STDERR_MAX_BYTES = 16 * 1024; // 16KB
+const SUMMARY_MAX_CHARS = 500;
+
+/**
+ * Extracts a human-readable summary from stream-json stdout.
+ * Finds the last assistant message's text content blocks.
+ * Falls back to raw stdout slice if parsing yields nothing.
+ */
+function extractSummaryFromStreamJson(stdout: string): string {
+ const lines = stdout.split('\n');
+ let lastText = '';
+
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const trimmed = lines[i].trim();
+ if (!trimmed) continue;
+ try {
+ const parsed = JSON.parse(trimmed) as Record;
+ if (parsed.type !== 'assistant') continue;
+
+ const content = (parsed.content ??
+ (parsed.message as Record | undefined)?.content) as
+ | Array<{ type?: string; text?: string }>
+ | undefined;
+ if (!Array.isArray(content)) continue;
+
+ for (const block of content) {
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
+ lastText = block.text.trim();
+ }
+ }
+ if (lastText) break;
+ } catch {
+ // skip non-JSON lines
+ }
+ }
+
+ return (lastText || stdout).slice(0, SUMMARY_MAX_CHARS);
+}
+
+export interface ScheduledTaskResult {
+ exitCode: number | null;
+ stdout: string;
+ stderr: string;
+ summary: string;
+ durationMs: number;
+}
+
+export interface ExecutionRequest {
+ runId: string;
+ config: ScheduleLaunchConfig;
+ maxTurns: number;
+ maxBudgetUsd?: number;
+}
+
+/**
+ * Internal extension of ScheduleRun with pinned storage path.
+ * Used by SchedulerService to ensure writes go to the correct path
+ * even if claudeRootPath changes mid-run.
+ */
+export interface InternalScheduleRun extends ScheduleRun {
+ storageBasePath: string;
+}
+
+export class ScheduledTaskExecutor {
+ private activeProcesses = new Map();
+
+ async execute(request: ExecutionRequest): Promise {
+ const startTime = Date.now();
+
+ const binaryPath = await ClaudeBinaryResolver.resolve();
+ if (!binaryPath) {
+ throw new Error('Claude CLI binary not found');
+ }
+
+ const shellEnv = await resolveInteractiveShellEnv();
+
+ const args = this.buildArgs(request);
+
+ logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
+
+ const child = spawnCli(binaryPath, args, {
+ cwd: request.config.cwd,
+ env: { ...process.env, ...shellEnv, CLAUDECODE: undefined },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ this.activeProcesses.set(request.runId, child);
+
+ try {
+ const result = await this.waitForExit(child, request.runId);
+ const durationMs = Date.now() - startTime;
+
+ return {
+ exitCode: result.exitCode,
+ stdout: result.stdout,
+ stderr: result.stderr,
+ summary: extractSummaryFromStreamJson(result.stdout),
+ durationMs,
+ };
+ } finally {
+ this.activeProcesses.delete(request.runId);
+ }
+ }
+
+ cancel(runId: string): boolean {
+ const child = this.activeProcesses.get(runId);
+ if (!child) {
+ return false;
+ }
+ logger.info(`[${runId}] Cancelling active run`);
+ killProcessTree(child, 'SIGTERM');
+ this.activeProcesses.delete(runId);
+ return true;
+ }
+
+ cancelAll(): void {
+ for (const [runId, child] of this.activeProcesses) {
+ logger.info(`[${runId}] Cancelling (shutdown)`);
+ killProcessTree(child, 'SIGTERM');
+ }
+ this.activeProcesses.clear();
+ }
+
+ get activeCount(): number {
+ return this.activeProcesses.size;
+ }
+
+ private buildArgs(request: ExecutionRequest): string[] {
+ const { config, maxTurns, maxBudgetUsd } = request;
+ const args: string[] = [
+ '-p',
+ config.prompt,
+ '--output-format',
+ 'stream-json',
+ '--verbose',
+ '--max-turns',
+ String(maxTurns),
+ '--no-session-persistence',
+ ];
+
+ if (maxBudgetUsd != null) {
+ args.push('--max-budget-usd', String(maxBudgetUsd));
+ }
+
+ if (config.model) {
+ args.push('--model', config.model);
+ }
+
+ if (config.skipPermissions !== false) {
+ args.push('--dangerously-skip-permissions');
+ }
+
+ if (config.allowedTools?.length) {
+ args.push('--allowed-tools', config.allowedTools.join(','));
+ }
+
+ if (config.disallowedTools?.length) {
+ args.push('--disallowed-tools', config.disallowedTools.join(','));
+ }
+
+ return args;
+ }
+
+ private waitForExit(
+ child: ChildProcess,
+ runId: string
+ ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const stderrChunks: Buffer[] = [];
+ let stdoutBytes = 0;
+ let stderrBytes = 0;
+
+ child.stdout?.on('data', (chunk: Buffer) => {
+ if (stdoutBytes < STDOUT_MAX_BYTES) {
+ stdoutChunks.push(chunk);
+ stdoutBytes += chunk.length;
+ }
+ });
+
+ child.stderr?.on('data', (chunk: Buffer) => {
+ if (stderrBytes < STDERR_MAX_BYTES) {
+ stderrChunks.push(chunk);
+ stderrBytes += chunk.length;
+ }
+ });
+
+ child.once('error', (error) => {
+ logger.error(`[${runId}] Process error: ${error.message}`);
+ reject(error);
+ });
+
+ child.once('close', (code) => {
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8').slice(0, STDOUT_MAX_BYTES);
+ const stderr = Buffer.concat(stderrChunks).toString('utf8').slice(0, STDERR_MAX_BYTES);
+
+ logger.info(`[${runId}] Process exited with code ${code}`);
+ resolve({ exitCode: code, stdout, stderr });
+ });
+ });
+ }
+}
diff --git a/src/main/services/schedule/SchedulerService.ts b/src/main/services/schedule/SchedulerService.ts
new file mode 100644
index 00000000..94bcb803
--- /dev/null
+++ b/src/main/services/schedule/SchedulerService.ts
@@ -0,0 +1,850 @@
+/**
+ * SchedulerService — orchestrates scheduled task execution via croner.
+ *
+ * Manages cron jobs, warm-up timers, execution lifecycle, and concurrency locks.
+ * Uses one-shot `claude -p` executor (NOT launchTeam stream-json).
+ */
+
+import { getSchedulesBasePath } from '@main/utils/pathDecoder';
+import { createLogger } from '@shared/utils/logger';
+import { Cron } from 'croner';
+import { randomUUID } from 'crypto';
+
+import type {
+ CreateScheduleInput,
+ Schedule,
+ ScheduleChangeEvent,
+ ScheduleRun,
+ ScheduleRunStatus,
+ UpdateSchedulePatch,
+} from '@shared/types';
+import type { ScheduleRepository } from './ScheduleRepository';
+import type { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
+
+const logger = createLogger('Service:Scheduler');
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const WARMUP_RETRY_DELAY_MS = 60_000;
+const WARMUP_MAX_RETRIES = 3;
+const EXECUTION_MAX_RETRIES = 2;
+const EXECUTION_RETRY_DELAY_MS = 90_000; // 90s between retries
+
+// =============================================================================
+// Types
+// =============================================================================
+
+type ChangeEmitter = (event: ScheduleChangeEvent) => void;
+
+/** Warm-up function injected from main process (wraps prepareForProvisioning) */
+export type WarmUpFn = (cwd: string) => Promise<{ ready: boolean; message: string }>;
+
+// =============================================================================
+// SchedulerService
+// =============================================================================
+
+export class SchedulerService {
+ private repository: ScheduleRepository;
+ private executor: ScheduledTaskExecutor;
+ private warmUpFn: WarmUpFn;
+ private changeEmitter: ChangeEmitter | null = null;
+
+ // Croner jobs keyed by schedule ID
+ private cronJobs = new Map();
+
+ // Warm-up timers keyed by schedule ID (includes warm-up retry timers)
+ private warmUpTimers = new Map>();
+
+ // Execution retry delay timers keyed by schedule ID
+ private retryDelayTimers = new Map>();
+
+ // Active runs keyed by schedule ID (only one run per schedule at a time)
+ private activeRuns = new Map();
+
+ // CWD exclusion lock: cwd → schedule ID (prevents two schedule runs on same dir)
+ private cwdLock = new Map();
+
+ // Flag to prevent retry timers from firing after stop()
+ private stopped = false;
+
+ constructor(
+ repository: ScheduleRepository,
+ executor: ScheduledTaskExecutor,
+ warmUpFn?: WarmUpFn
+ ) {
+ this.repository = repository;
+ this.executor = executor;
+ this.warmUpFn = warmUpFn ?? (async () => ({ ready: true, message: 'warm-up skipped' }));
+ }
+
+ setChangeEmitter(emitter: ChangeEmitter): void {
+ this.changeEmitter = emitter;
+ }
+
+ // ===========================================================================
+ // Lifecycle
+ // ===========================================================================
+
+ async start(): Promise