feat: implement runtime control API for team management

- Added runtime control API to manage team lifecycle, including launch and stop functionalities.
- Integrated runtime state retrieval to provide current status of teams.
- Enhanced tests to validate the new runtime control features, ensuring proper functionality and error handling.
- Updated type definitions to include runtime API methods for better type safety and clarity in team management.
This commit is contained in:
iliya 2026-03-12 17:01:44 +02:00
parent 19f2fa76d0
commit 4a0b1aa698
18 changed files with 1540 additions and 142 deletions

View file

@ -6,6 +6,7 @@ const messages = require('./internal/messages.js');
const processes = require('./internal/processes.js');
const maintenance = require('./internal/maintenance.js');
const crossTeam = require('./internal/crossTeam.js');
const runtime = require('./internal/runtime.js');
function bindModule(context, moduleApi) {
return Object.fromEntries(
@ -28,6 +29,7 @@ function createController(options) {
processes: bindModule(context, processes),
maintenance: bindModule(context, maintenance),
crossTeam: bindModule(context, crossTeam),
runtime: bindModule(context, runtime),
};
}
@ -41,4 +43,5 @@ module.exports = {
processes,
maintenance,
crossTeam,
runtime,
};

View file

@ -0,0 +1,267 @@
const fs = require('fs');
const path = require('path');
const READY_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']);
const DEFAULT_WAIT_TIMEOUT_MS = 120000;
const MIN_WAIT_TIMEOUT_MS = 1000;
const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const POLL_INTERVAL_MS = 1000;
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeTimeoutMs(rawValue) {
const numeric =
typeof rawValue === 'number' && Number.isFinite(rawValue)
? Math.floor(rawValue)
: DEFAULT_WAIT_TIMEOUT_MS;
return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric));
}
function getControlApiStatePath(context) {
return path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE);
}
function readControlApiState(context) {
const filePath = getControlApiStatePath(context);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.baseUrl === 'string' && parsed.baseUrl.trim()) {
return parsed.baseUrl.trim();
}
return null;
} catch (error) {
if (error && error.code === 'ENOENT') {
return null;
}
throw error;
}
}
function resolveControlBaseUrl(context, flags = {}) {
const explicit =
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
(typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string' &&
process.env.CLAUDE_TEAM_CONTROL_URL.trim()) ||
readControlApiState(context);
if (!explicit) {
throw new Error(
'Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL.'
);
}
return explicit;
}
async function requestJson(baseUrl, pathname, options = {}) {
const controller = new AbortController();
const timeoutMs = normalizeTimeoutMs(options.timeoutMs || 10000);
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${baseUrl}${pathname}`, {
method: options.method || 'GET',
headers: {
accept: 'application/json',
...(options.body ? { 'content-type': 'application/json' } : {}),
},
...(options.body ? { body: JSON.stringify(options.body) } : {}),
signal: controller.signal,
});
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
const detail =
payload && typeof payload.error === 'string' && payload.error.trim()
? payload.error.trim()
: `${response.status} ${response.statusText}`.trim();
throw new Error(detail || 'Team control API request failed');
}
return payload;
} catch (error) {
if (error && error.name === 'AbortError') {
throw new Error(`Timed out calling team control API: ${pathname}`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
function buildLaunchRequest(flags = {}) {
const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : '';
if (!cwd) {
throw new Error('Missing cwd');
}
return {
cwd,
...(typeof flags.prompt === 'string' && flags.prompt.trim()
? { prompt: flags.prompt.trim() }
: {}),
...(typeof flags.model === 'string' && flags.model.trim()
? { model: flags.model.trim() }
: {}),
...(typeof flags.effort === 'string' && flags.effort.trim()
? { effort: flags.effort.trim() }
: {}),
...(typeof flags.clearContext === 'boolean' ? { clearContext: flags.clearContext } : {}),
...(typeof flags['clear-context'] === 'boolean'
? { clearContext: flags['clear-context'] }
: {}),
...(typeof flags.skipPermissions === 'boolean'
? { skipPermissions: flags.skipPermissions }
: {}),
...(typeof flags['skip-permissions'] === 'boolean'
? { skipPermissions: flags['skip-permissions'] }
: {}),
...(typeof flags.worktree === 'string' && flags.worktree.trim()
? { worktree: flags.worktree.trim() }
: {}),
...(typeof flags.extraCliArgs === 'string' && flags.extraCliArgs.trim()
? { extraCliArgs: flags.extraCliArgs.trim() }
: {}),
...(typeof flags['extra-cli-args'] === 'string' && flags['extra-cli-args'].trim()
? { extraCliArgs: flags['extra-cli-args'].trim() }
: {}),
};
}
function shouldWaitForReady(flags = {}) {
if (typeof flags.waitForReady === 'boolean') {
return flags.waitForReady;
}
if (typeof flags['wait-for-ready'] === 'boolean') {
return flags['wait-for-ready'];
}
return true;
}
function shouldWaitForStop(flags = {}) {
if (typeof flags.waitForStop === 'boolean') {
return flags.waitForStop;
}
if (typeof flags['wait-for-stop'] === 'boolean') {
return flags['wait-for-stop'];
}
return true;
}
async function waitForProvisioningState(baseUrl, teamName, runId, timeoutMs) {
const startedAt = Date.now();
let lastProgress = null;
while (Date.now() - startedAt <= timeoutMs) {
const progress = await requestJson(baseUrl, `/api/teams/provisioning/${encodeURIComponent(runId)}`, {
timeoutMs: Math.min(timeoutMs, 10000),
});
lastProgress = progress;
if (progress && READY_STATES.has(progress.state)) {
if (progress.state !== 'ready') {
const suffix =
progress && typeof progress.error === 'string' && progress.error.trim()
? `: ${progress.error.trim()}`
: '';
throw new Error(`Team ${teamName} did not become ready (${progress.state})${suffix}`);
}
return {
teamName,
runId,
isAlive: true,
progress,
};
}
await sleep(POLL_INTERVAL_MS);
}
const stateLabel =
lastProgress && typeof lastProgress.state === 'string' ? ` while in state ${lastProgress.state}` : '';
throw new Error(`Timed out waiting for team ${teamName} to become ready${stateLabel}`);
}
async function waitForStopped(baseUrl, teamName, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt <= timeoutMs) {
const runtime = await requestJson(
baseUrl,
`/api/teams/${encodeURIComponent(teamName)}/runtime`,
{ timeoutMs: Math.min(timeoutMs, 10000) }
);
if (!runtime || runtime.isAlive !== true) {
return runtime;
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`Timed out waiting for team ${teamName} to stop`);
}
async function launchTeam(context, flags = {}) {
const baseUrl = resolveControlBaseUrl(context, flags);
const request = buildLaunchRequest(flags);
const launch = await requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/launch`, {
method: 'POST',
body: request,
});
if (!shouldWaitForReady(flags)) {
return {
teamName: context.teamName,
waitForReady: false,
...launch,
};
}
return waitForProvisioningState(
baseUrl,
context.teamName,
launch.runId,
normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'])
);
}
async function stopTeam(context, flags = {}) {
const baseUrl = resolveControlBaseUrl(context, flags);
const stopped = await requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/stop`, {
method: 'POST',
});
if (!shouldWaitForStop(flags)) {
return stopped;
}
return waitForStopped(
baseUrl,
context.teamName,
normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'])
);
}
async function getRuntimeState(context, flags = {}) {
const baseUrl = resolveControlBaseUrl(context, flags);
return requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`);
}
module.exports = {
launchTeam,
stopTeam,
getRuntimeState,
};

View file

@ -1,4 +1,5 @@
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
@ -27,6 +28,36 @@ describe('agent-teams-controller API', () => {
return dir;
}
async function startControlServer(handler) {
const server = http.createServer(async (req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
try {
const bodyText = Buffer.concat(chunks).toString('utf8');
const body = bodyText ? JSON.parse(bodyText) : undefined;
const result = await handler({
method: req.method,
url: req.url,
body,
});
res.writeHead(result.statusCode || 200, { 'content-type': 'application/json' });
res.end(JSON.stringify(result.body));
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
};
}
it('creates tasks and exposes grouped controller modules', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -560,4 +591,93 @@ describe('agent-teams-controller API', () => {
'This should persist despite notification failure.'
);
});
it('launches and stops a team through the runtime control API bridge', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/my-team/launch') {
return { body: { runId: 'run-123' } };
}
if (method === 'GET' && url === '/api/teams/provisioning/run-123') {
return {
body: {
runId: 'run-123',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
};
}
if (method === 'POST' && url === '/api/teams/my-team/stop') {
return {
body: {
teamName: 'my-team',
isAlive: false,
runId: null,
progress: null,
},
};
}
if (method === 'GET' && url === '/api/teams/my-team/runtime') {
return {
body: {
teamName: 'my-team',
isAlive: false,
runId: null,
progress: null,
},
};
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const launched = await controller.runtime.launchTeam({
cwd: '/tmp/project',
controlUrl: server.baseUrl,
});
expect(launched.runId).toBe('run-123');
expect(launched.isAlive).toBe(true);
expect(launched.progress.state).toBe('ready');
const stopped = await controller.runtime.stopTeam({
controlUrl: server.baseUrl,
});
expect(stopped.isAlive).toBe(false);
expect(stopped.runId).toBeNull();
expect(calls).toEqual([
{
method: 'POST',
url: '/api/teams/my-team/launch',
body: { cwd: '/tmp/project' },
},
{
method: 'GET',
url: '/api/teams/provisioning/run-123',
body: undefined,
},
{
method: 'POST',
url: '/api/teams/my-team/stop',
body: undefined,
},
{
method: 'GET',
url: '/api/teams/my-team/runtime',
body: undefined,
},
]);
} finally {
await server.close();
}
});
});

View file

@ -66,6 +66,12 @@ declare module 'agent-teams-controller' {
getCrossTeamOutbox(): unknown;
}
export interface ControllerRuntimeApi {
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
@ -74,6 +80,7 @@ declare module 'agent-teams-controller' {
processes: ControllerProcessApi;
maintenance: ControllerMaintenanceApi;
crossTeam: ControllerCrossTeamApi;
runtime: ControllerRuntimeApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;

View file

@ -5,6 +5,7 @@ import { registerKanbanTools } from './kanbanTools';
import { registerMessageTools } from './messageTools';
import { registerProcessTools } from './processTools';
import { registerReviewTools } from './reviewTools';
import { registerRuntimeTools } from './runtimeTools';
import { registerTaskTools } from './taskTools';
export function registerTools(server: FastMCP) {
@ -13,5 +14,6 @@ export function registerTools(server: FastMCP) {
registerReviewTools(server);
registerMessageTools(server);
registerProcessTools(server);
registerRuntimeTools(server);
registerCrossTeamTools(server);
}

View file

@ -0,0 +1,78 @@
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(),
controlUrl: z.string().url().optional(),
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
};
export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'team_launch',
description: 'Launch a provisioned team via the desktop runtime',
parameters: z.object({
...toolContextSchema,
cwd: z.string().min(1),
prompt: z.string().min(1).optional(),
model: z.string().min(1).optional(),
effort: z.enum(['low', 'medium', 'high']).optional(),
clearContext: z.boolean().optional(),
skipPermissions: z.boolean().optional(),
worktree: z.string().min(1).optional(),
extraCliArgs: z.string().min(1).optional(),
waitForReady: z.boolean().optional(),
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
cwd,
prompt,
model,
effort,
clearContext,
skipPermissions,
worktree,
extraCliArgs,
waitForReady,
}) =>
jsonTextContent(
await getController(teamName, claudeDir).runtime.launchTeam({
cwd,
...(prompt ? { prompt } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(clearContext !== undefined ? { clearContext } : {}),
...(skipPermissions !== undefined ? { skipPermissions } : {}),
...(worktree ? { worktree } : {}),
...(extraCliArgs ? { extraCliArgs } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
...(waitForReady !== undefined ? { waitForReady } : {}),
})
),
});
server.addTool({
name: 'team_stop',
description: 'Stop a running team via the desktop runtime',
parameters: z.object({
...toolContextSchema,
waitForStop: z.boolean().optional(),
}),
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
jsonTextContent(
await getController(teamName, claudeDir).runtime.stopTeam({
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
...(waitForStop !== undefined ? { waitForStop } : {}),
})
),
});
}

View file

@ -1,4 +1,5 @@
import fs from 'fs';
import http from 'http';
import os from 'os';
import path from 'path';
@ -61,6 +62,8 @@ describe('agent-teams-mcp tools', () => {
'task_set_status',
'task_start',
'task_unlink',
'team_launch',
'team_stop',
] as const;
function getTool(name: string) {
@ -73,6 +76,45 @@ describe('agent-teams-mcp tools', () => {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
}
async function startControlServer(
handler: (request: {
method?: string;
url?: string;
body?: unknown;
}) => Promise<{ statusCode?: number; body: unknown }> | { statusCode?: number; body: unknown }
) {
const server = http.createServer(async (req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
try {
const bodyText = Buffer.concat(chunks).toString('utf8');
const body = bodyText ? JSON.parse(bodyText) : undefined;
const result = await handler({ method: req.method, url: req.url, body });
res.writeHead(result.statusCode ?? 200, { 'content-type': 'application/json' });
res.end(JSON.stringify(result.body));
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to bind control server');
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () =>
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
),
};
}
it('registers the full expected MCP tool surface', () => {
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
});
@ -89,6 +131,97 @@ describe('agent-teams-mcp tools', () => {
expect(parsed?.success).toBe(true);
});
it('launches and stops teams through the runtime MCP tools', async () => {
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/alpha/launch') {
return { body: { runId: 'run-555' } };
}
if (method === 'GET' && url === '/api/teams/provisioning/run-555') {
return {
body: {
runId: 'run-555',
teamName: 'alpha',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:02.000Z',
},
};
}
if (method === 'POST' && url === '/api/teams/alpha/stop') {
return {
body: {
teamName: 'alpha',
isAlive: false,
runId: null,
progress: null,
},
};
}
if (method === 'GET' && url === '/api/teams/alpha/runtime') {
return {
body: {
teamName: 'alpha',
isAlive: false,
runId: null,
progress: null,
},
};
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const launched = parseJsonToolResult(
await getTool('team_launch').execute({
teamName: 'alpha',
cwd: '/tmp/project',
controlUrl: server.baseUrl,
})
);
expect(launched.runId).toBe('run-555');
expect(launched.isAlive).toBe(true);
expect(launched.progress.state).toBe('ready');
const stopped = parseJsonToolResult(
await getTool('team_stop').execute({
teamName: 'alpha',
controlUrl: server.baseUrl,
})
);
expect(stopped.isAlive).toBe(false);
expect(calls).toEqual([
{
method: 'POST',
url: '/api/teams/alpha/launch',
body: { cwd: '/tmp/project' },
},
{
method: 'GET',
url: '/api/teams/provisioning/run-555',
body: undefined,
},
{
method: 'POST',
url: '/api/teams/alpha/stop',
body: undefined,
},
{
method: 'GET',
url: '/api/teams/alpha/runtime',
body: undefined,
},
]);
} finally {
await server.close();
}
});
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'alpha';

View file

@ -15,6 +15,7 @@ import { registerSearchRoutes } from './search';
import { registerSessionRoutes } from './sessions';
import { registerSshRoutes } from './ssh';
import { registerSubagentRoutes } from './subagents';
import { registerTeamRoutes } from './teams';
import { registerUpdaterRoutes } from './updater';
import { registerUtilityRoutes } from './utility';
import { registerValidationRoutes } from './validation';
@ -28,6 +29,7 @@ import type {
UpdaterService,
} from '../services';
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
import type { TeamProvisioningService } from '../services/team/TeamProvisioningService';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:routes');
@ -40,6 +42,7 @@ export interface HttpServices {
dataCache: DataCache;
updaterService: UpdaterService;
sshConnectionManager: SshConnectionManager;
teamProvisioningService?: TeamProvisioningService;
}
export function registerHttpRoutes(
@ -51,6 +54,7 @@ export function registerHttpRoutes(
registerSessionRoutes(app, services);
registerSearchRoutes(app, services);
registerSubagentRoutes(app, services);
registerTeamRoutes(app, services);
registerNotificationRoutes(app);
registerConfigRoutes(app);
registerValidationRoutes(app);

216
src/main/http/teams.ts Normal file
View file

@ -0,0 +1,216 @@
import { validateTeamName } from '@main/ipc/guards';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { isAbsolute } from 'path';
import type { HttpServices } from './index';
import type { EffortLevel, TeamLaunchRequest } from '@shared/types/team';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:teams');
type LaunchBody = Omit<TeamLaunchRequest, 'teamName'>;
const EFFORT_LEVELS = new Set<EffortLevel>(['low', 'medium', 'high']);
class HttpBadRequestError extends Error {}
function getTeamProvisioningService(services: HttpServices) {
if (!services.teamProvisioningService) {
throw new Error('Team runtime control is not available in this mode');
}
return services.teamProvisioningService;
}
function assertAbsoluteCwd(cwd: unknown): string {
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
throw new HttpBadRequestError('cwd must be a non-empty string');
}
const normalized = cwd.trim();
if (!isAbsolute(normalized)) {
throw new HttpBadRequestError('cwd must be an absolute path');
}
return normalized;
}
function assertOptionalString(value: unknown, fieldName: string): string | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== 'string') {
throw new HttpBadRequestError(`${fieldName} must be a string`);
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
function assertOptionalBoolean(value: unknown, fieldName: string): boolean | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== 'boolean') {
throw new HttpBadRequestError(`${fieldName} must be a boolean`);
}
return value;
}
function assertOptionalEffort(value: unknown): EffortLevel | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== 'string' || !EFFORT_LEVELS.has(value as EffortLevel)) {
throw new HttpBadRequestError('effort must be one of: low, medium, high');
}
return value as EffortLevel;
}
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const prompt = assertOptionalString(payload.prompt, 'prompt');
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
const worktree = assertOptionalString(payload.worktree, 'worktree');
const extraCliArgs = assertOptionalString(payload.extraCliArgs, 'extraCliArgs');
return {
teamName,
cwd: assertAbsoluteCwd(payload.cwd),
...(prompt && {
prompt,
}),
...(model && {
model,
}),
...(effort && {
effort,
}),
...(clearContext !== undefined && {
clearContext,
}),
...(skipPermissions !== undefined && {
skipPermissions,
}),
...(worktree && {
worktree,
}),
...(extraCliArgs && {
extraCliArgs,
}),
};
}
export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void {
app.post<{ Params: { teamName: string }; Body: LaunchBody }>(
'/api/teams/:teamName/launch',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const launchRequest = parseLaunchRequest(validatedTeamName.value!, request.body);
const response = await getTeamProvisioningService(services).launchTeam(
launchRequest,
() => undefined
);
return reply.send(response);
} catch (error) {
const statusCode = error instanceof HttpBadRequestError ? 400 : 500;
if (!(error instanceof HttpBadRequestError)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/launch:`,
getErrorMessage(error)
);
}
return reply.status(statusCode).send({ error: getErrorMessage(error) });
}
}
);
app.post<{ Params: { teamName: string } }>(
'/api/teams/:teamName/stop',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const teamProvisioningService = getTeamProvisioningService(services);
teamProvisioningService.stopTeam(validatedTeamName.value!);
return reply.send(teamProvisioningService.getRuntimeState(validatedTeamName.value!));
} catch (error) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/stop:`,
getErrorMessage(error)
);
return reply.status(500).send({ error: getErrorMessage(error) });
}
}
);
app.get<{ Params: { teamName: string } }>(
'/api/teams/:teamName/runtime',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
return reply.send(
getTeamProvisioningService(services).getRuntimeState(validatedTeamName.value!)
);
} catch (error) {
logger.error(
`Error in GET /api/teams/${request.params.teamName}/runtime:`,
getErrorMessage(error)
);
return reply.status(500).send({ error: getErrorMessage(error) });
}
}
);
app.get<{ Params: { runId: string } }>(
'/api/teams/provisioning/:runId',
async (request, reply) => {
try {
const runId = request.params.runId?.trim();
if (!runId) {
return reply.status(400).send({ error: 'runId is required' });
}
return reply.send(await getTeamProvisioningService(services).getProvisioningStatus(runId));
} catch (error) {
const message = getErrorMessage(error);
const statusCode = message === 'Unknown runId' ? 404 : 500;
logger.error(`Error in GET /api/teams/provisioning/${request.params.runId}:`, message);
return reply.status(statusCode).send({ error: message });
}
}
);
app.get('/api/teams/runtime/alive', async (_request, reply) => {
try {
const teamProvisioningService = getTeamProvisioningService(services);
const runtimeStates = teamProvisioningService
.getAliveTeams()
.map((teamName) => teamProvisioningService.getRuntimeState(teamName));
return reply.send(runtimeStates);
} catch (error) {
logger.error('Error in GET /api/teams/runtime/alive:', getErrorMessage(error));
return reply.status(500).send({ error: getErrorMessage(error) });
}
});
}

View file

@ -54,6 +54,11 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
import { HttpServer } from './services/infrastructure/HttpServer';
import { TeamInboxReader } from './services/team/TeamInboxReader';
import {
buildTeamControlApiBaseUrl,
clearTeamControlApiState,
writeTeamControlApiState,
} from './services/team/TeamControlApiState';
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
import { getAppIconPath } from './utils/appIcon';
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
@ -362,6 +367,24 @@ function getRendererIndexPath(): string {
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
}
function getTeamControlApiBaseUrl(): string | null {
if (!httpServer?.isRunning()) {
return null;
}
return buildTeamControlApiBaseUrl(httpServer.getPort());
}
async function syncTeamControlApiState(): Promise<void> {
const baseUrl = getTeamControlApiBaseUrl();
if (!baseUrl) {
await clearTeamControlApiState();
return;
}
await writeTeamControlApiState(baseUrl);
}
/**
* Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients.
* Cleans up previous listeners before adding new ones.
@ -735,6 +758,13 @@ function initializeServices(): void {
// warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer();
teamProvisioningService.setControlApiBaseUrlResolver(async () => {
if (!httpServer.isRunning()) {
await startHttpServer(handleModeSwitch);
}
return getTeamControlApiBaseUrl();
});
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
const teamChangeEmitter = (event: TeamChangeEvent): void => {
@ -841,6 +871,11 @@ async function startHttpServer(
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
): Promise<void> {
try {
if (httpServer.isRunning()) {
await syncTeamControlApiState();
return;
}
const config = configManager.getConfig();
const activeContext = contextRegistry.getActive();
const port = await httpServer.start(
@ -852,12 +887,15 @@ async function startHttpServer(
dataCache: activeContext.dataCache,
updaterService,
sshConnectionManager,
teamProvisioningService,
},
modeSwitchHandler,
config.httpServer?.port ?? 3456
);
await syncTeamControlApiState();
logger.info(`HTTP sidecar server running on port ${port}`);
} catch (error) {
await clearTeamControlApiState().catch(() => undefined);
logger.error('Failed to start HTTP server:', error);
}
}
@ -872,6 +910,7 @@ function shutdownServices(): void {
if (httpServer?.isRunning()) {
void httpServer.stop();
}
void clearTeamControlApiState();
// Clean up file watcher event listeners
if (fileChangeCleanup) {

View file

@ -11,6 +11,7 @@ import { createLogger } from '@shared/utils/logger';
import { type IpcMain } from 'electron';
import { configManager } from '../services';
import { clearTeamControlApiState } from '../services/team/TeamControlApiState';
import type { HttpServer } from '../services/infrastructure/HttpServer';
@ -84,6 +85,7 @@ async function handleStop(): Promise<{
}> {
try {
await httpServer.stop();
await clearTeamControlApiState();
configManager.updateConfig('httpServer', { enabled: false });
return { success: true, data: { running: false, port: httpServer.getPort() } };
} catch (error) {

View file

@ -23,7 +23,6 @@ import type {
FileChangeSummary,
FileEditEvent,
FileEditTimeline,
MemberLogSummary,
SnippetDiff,
TaskChangeScope,
TaskChangeSetV2,
@ -277,8 +276,11 @@ export class ChangeExtractorService {
includeDetails: boolean
): Promise<TaskChangeSetV2> {
const taskMeta = await this.readTaskMeta(teamName, taskId);
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
const logRefs = await this.logsFinder.findLogFileRefsForTask(
teamName,
taskId,
effectiveOptions
);
if (logRefs.length === 0) {
return this.emptyTaskChangeSet(teamName, taskId);
}
@ -883,47 +885,6 @@ export class ChangeExtractorService {
return false;
}
/** Конвертировать MemberLogSummary[] в LogFileRef[] */
private async resolveLogFileRefs(
teamName: string,
logs: MemberLogSummary[]
): Promise<LogFileRef[]> {
const refs: LogFileRef[] = [];
const logsNeedingResolve: MemberLogSummary[] = [];
for (const log of logs) {
const memberName = log.memberName ?? 'unknown';
if (log.filePath) {
refs.push({ filePath: log.filePath, memberName });
} else {
logsNeedingResolve.push(log);
}
}
if (logsNeedingResolve.length === 0) return refs;
const byMember = new Map<string, MemberLogSummary[]>();
for (const log of logsNeedingResolve) {
const name = log.memberName ?? 'unknown';
if (!byMember.has(name)) byMember.set(name, []);
byMember.get(name)!.push(log);
}
for (const [memberName, memberLogs] of byMember) {
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
for (const log of memberLogs) {
const matchedPath = paths.find((p) =>
log.kind === 'subagent'
? p.includes(log.sessionId) && p.includes(log.subagentId)
: p.includes(log.sessionId) && p.endsWith('.jsonl')
);
if (matchedPath) {
refs.push({ filePath: matchedPath, memberName });
}
}
}
return refs;
}
/** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */
private async extractFilteredChanges(
logRefs: LogFileRef[],
@ -1260,8 +1221,11 @@ export class ChangeExtractorService {
return;
}
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
const logRefs = await this.logsFinder.findLogFileRefsForTask(
teamName,
taskId,
effectiveOptions
);
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) {
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
@ -1304,8 +1268,11 @@ export class ChangeExtractorService {
return;
}
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
const logRefs = await this.logsFinder.findLogFileRefsForTask(
teamName,
taskId,
effectiveOptions
);
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
const projectFingerprint = await this.computeProjectFingerprint(teamName);
if (!sourceFingerprint || !projectFingerprint) {
@ -1346,12 +1313,12 @@ export class ChangeExtractorService {
return null;
}
}
return createHash('sha1').update(parts.join('|')).digest('hex');
return createHash('sha256').update(parts.join('|')).digest('hex');
}
private async computeProjectFingerprint(teamName: string): Promise<string | null> {
const projectPath = await this.resolveProjectPath(teamName);
if (!projectPath) return null;
return createHash('sha1').update(this.normalizeFilePathKey(projectPath)).digest('hex');
return createHash('sha256').update(this.normalizeFilePathKey(projectPath)).digest('hex');
}
}

View file

@ -0,0 +1,47 @@
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { rm } from 'fs/promises';
import path from 'path';
const logger = createLogger('Service:TeamControlApiState');
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
function normalizeBaseUrlHost(host: string): string {
if (host === '0.0.0.0' || host === '::') {
return '127.0.0.1';
}
return host;
}
export function buildTeamControlApiBaseUrl(port: number, host: string = '127.0.0.1'): string {
return `http://${normalizeBaseUrlHost(host)}:${port}`;
}
function getTeamControlApiStatePath(): string {
return path.join(getClaudeBasePath(), TEAM_CONTROL_API_STATE_FILE);
}
export async function writeTeamControlApiState(baseUrl: string): Promise<void> {
const statePath = getTeamControlApiStatePath();
await atomicWriteAsync(
statePath,
JSON.stringify(
{
baseUrl,
pid: process.pid,
updatedAt: new Date().toISOString(),
},
null,
2
)
);
logger.info(`Published team control API endpoint: ${baseUrl}`);
}
export async function clearTeamControlApiState(): Promise<void> {
const statePath = getTeamControlApiStatePath();
await rm(statePath, { force: true }).catch(() => undefined);
}

View file

@ -212,23 +212,14 @@ export class TeamMemberLogsFinder {
// Back-compat: single since timestamp -> treat as open interval.
const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN;
const sinceMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null;
const sinceStartMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null;
const effectiveIntervals =
normalizedIntervals.length > 0
? normalizedIntervals
: sinceMs != null
? [{ startMs: sinceMs, endMs: null }]
: sinceStartMs != null
? [{ startMs: sinceStartMs, endMs: null }]
: [];
const overlapsAnyInterval = (logStartMs: number, logEndMs: number): boolean => {
for (const it of effectiveIntervals) {
const start = it.startMs - TASK_LOG_INTERVAL_GRACE_MS;
const end = (it.endMs ?? now) + TASK_LOG_INTERVAL_GRACE_MS;
if (logStartMs <= end && logEndMs >= start) return true;
}
return false;
};
const filteredOwnerLogs = ownerLogs.filter((log) => {
if (log.isOngoing) return true;
const startMs = new Date(log.startTime).getTime();
@ -238,7 +229,13 @@ export class TeamMemberLogsFinder {
const endMs = startMs + durationMs;
if (effectiveIntervals.length > 0) {
return overlapsAnyInterval(startMs, endMs);
return this.logOverlapsIntervals(
startMs,
endMs,
effectiveIntervals,
now,
TASK_LOG_INTERVAL_GRACE_MS
);
}
return startMs >= now - fallbackRecentMs;
@ -268,6 +265,156 @@ export class TeamMemberLogsFinder {
);
}
/**
* Fast path for change extraction: returns task-related JSONL file refs directly without
* building full MemberLogSummary metadata for every matched log.
*/
async findLogFileRefsForTask(
teamName: string,
taskId: string,
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
): Promise<{ filePath: string; memberName: string }[]> {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return [];
const sinceMs = this.deriveSinceMs(options);
const { projectDir, config, sessionIds, knownMembers } = discovery;
const refs: { filePath: string; memberName: string; sortTime: number }[] = [];
const seen = new Set<string>();
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
const pushRef = (filePath: string, memberName: string, sortTime = 0): void => {
const key = `${memberName.toLowerCase()}:${filePath}`;
if (seen.has(key)) return;
seen.add(key);
refs.push({ filePath, memberName, sortTime });
};
if (config.leadSessionId) {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
try {
await fs.access(leadJsonl);
if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) {
const firstTimestamp = await this.probeFirstTimestamp(leadJsonl);
pushRef(leadJsonl, leadMemberName, await this.getSortTime(leadJsonl, firstTimestamp));
}
} catch {
// file missing or unreadable
}
}
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let files: string[];
try {
files = await fs.readdir(subagentsDir);
} catch {
continue;
}
for (const file of files) {
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
if (file.startsWith('agent-acompact')) continue;
const filePath = path.join(subagentsDir, file);
if (!(await this.fileMentionsTaskIdCached(filePath, teamName, taskId, false, sinceMs))) {
continue;
}
const attribution = await this.attributeSubagent(filePath, knownMembers);
if (!attribution) continue;
pushRef(
filePath,
attribution.detectedMember,
await this.getSortTime(filePath, attribution.firstTimestamp)
);
}
}
const normalizedOwner =
typeof options?.owner === 'string' ? options.owner.trim() : options?.owner;
const isLeadOwner =
typeof normalizedOwner === 'string' &&
normalizedOwner.length > 0 &&
normalizedOwner.toLowerCase() === leadMemberName.toLowerCase();
const ownerRelevantStatus =
options?.status === 'in_progress' || options?.status === 'completed';
const includeOwnerSessions =
ownerRelevantStatus &&
typeof normalizedOwner === 'string' &&
normalizedOwner.length > 0 &&
!isLeadOwner;
if (includeOwnerSessions) {
const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner);
const TASK_LOG_INTERVAL_GRACE_MS = 10_000;
const fallbackRecentMs = 30 * 60_000;
const now = Date.now();
const normalizedIntervals = Array.isArray(options?.intervals)
? options.intervals
.map((i) => {
const startMs = Date.parse(i.startedAt);
const endMsRaw =
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
return Number.isFinite(startMs) ? { startMs, endMs } : null;
})
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
: [];
const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN;
const sinceStartMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null;
const effectiveIntervals =
normalizedIntervals.length > 0
? normalizedIntervals
: sinceStartMs != null
? [{ startMs: sinceStartMs, endMs: null }]
: [];
for (const log of ownerLogs) {
if (!log.filePath) continue;
if (!log.isOngoing) {
const startMs = new Date(log.startTime).getTime();
if (!Number.isFinite(startMs)) continue;
const durationMs =
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
const endMs = startMs + durationMs;
if (effectiveIntervals.length > 0) {
if (
!this.logOverlapsIntervals(
startMs,
endMs,
effectiveIntervals,
now,
TASK_LOG_INTERVAL_GRACE_MS
)
) {
continue;
}
} else if (startMs < now - fallbackRecentMs) {
continue;
}
}
pushRef(
log.filePath,
log.memberName ?? normalizedOwner,
Number.isFinite(new Date(log.startTime).getTime()) ? new Date(log.startTime).getTime() : 0
);
}
}
const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime);
return sortedRefs.map(({ filePath, memberName }) => ({ filePath, memberName }));
}
/**
* Returns absolute paths to all JSONL files belonging to the specified member.
* Uses the same discovery logic as findMemberLogs but collects file paths.
@ -508,6 +655,21 @@ export class TeamMemberLogsFinder {
return earliest - TASK_SINCE_GRACE_MS;
}
private logOverlapsIntervals(
logStartMs: number,
logEndMs: number,
intervals: { startMs: number; endMs: number | null }[],
now: number,
graceMs: number
): boolean {
for (const it of intervals) {
const start = it.startMs - graceMs;
const end = (it.endMs ?? now) + graceMs;
if (logStartMs <= end && logEndMs >= start) return true;
}
return false;
}
private async fileMentionsTaskIdCached(
filePath: string,
teamName: string,
@ -649,8 +811,6 @@ export class TeamMemberLogsFinder {
const b = block as Record<string, unknown>;
if (b.type !== 'tool_use') continue;
const rawName = typeof b.name === 'string' ? b.name : '';
const toolName = rawName.replace(/^proxy_/, '');
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;
@ -739,7 +899,8 @@ export class TeamMemberLogsFinder {
// accurate timestamps and message count from the full file.
const metadata = await this.streamFileMetadata(filePath);
const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(filePath));
const firstTimestamp =
metadata.firstTimestamp ?? attribution.firstTimestamp ?? (await this.getFileMtime(filePath));
const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
const startTime = new Date(firstTimestamp);
@ -779,7 +940,11 @@ export class TeamMemberLogsFinder {
private async attributeSubagent(
filePath: string,
knownMembers: Set<string>
): Promise<{ detectedMember: string; description: string } | null> {
): Promise<{
detectedMember: string;
description: string;
firstTimestamp: string | null;
} | null> {
const lines: string[] = [];
try {
@ -806,8 +971,12 @@ export class TeamMemberLogsFinder {
let description = '';
let detectedMember: string | null = null;
let detectionPriority = 0;
let firstTimestamp: string | null = null;
for (const line of lines) {
if (!firstTimestamp) {
firstTimestamp = this.extractTimestampFromLine(line);
}
// Early exit: both objectives met (member detected at max priority + description found)
if (detectionPriority >= 3 && description) break;
@ -888,7 +1057,7 @@ export class TeamMemberLogsFinder {
if (!detectedMember) return null;
return { detectedMember, description };
return { detectedMember, description, firstTimestamp };
}
/**
@ -1016,10 +1185,10 @@ export class TeamMemberLogsFinder {
// Fast timestamp extraction without full JSON parse.
// ISO prefix anchor avoids false positives from "timestamp" inside string values.
const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(trimmed);
if (tsMatch) {
if (!firstTimestamp) firstTimestamp = tsMatch[1];
lastTimestamp = tsMatch[1];
const ts = this.extractTimestampFromLine(trimmed);
if (ts) {
if (!firstTimestamp) firstTimestamp = ts;
lastTimestamp = ts;
}
}
rl.close();
@ -1031,6 +1200,46 @@ export class TeamMemberLogsFinder {
return { firstTimestamp, lastTimestamp, messageCount };
}
private extractTimestampFromLine(line: string): string | null {
const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(line);
return tsMatch?.[1] ?? null;
}
private async probeFirstTimestamp(
filePath: string,
maxLines = ATTRIBUTION_SCAN_LINES
): Promise<string | null> {
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let seen = 0;
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
const ts = this.extractTimestampFromLine(trimmed);
if (ts) {
rl.close();
stream.destroy();
return ts;
}
seen++;
if (seen >= maxLines) break;
}
rl.close();
stream.destroy();
} catch {
// ignore
}
return null;
}
private async getSortTime(filePath: string, timestamp: string | null): Promise<number> {
const resolvedTimestamp = timestamp ?? (await this.getFileMtime(filePath));
const sortTime = Date.parse(resolvedTimestamp);
return Number.isFinite(sortTime) ? sortTime : 0;
}
private async getFileMtime(filePath: string): Promise<string> {
try {
const stat = await fs.stat(filePath);

View file

@ -69,6 +69,7 @@ import type {
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
TeamRuntimeState,
TeamTask,
ToolApprovalAutoResolved,
ToolApprovalEvent,
@ -761,7 +762,6 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
function buildProvisioningPrompt(request: TeamCreateRequest): string {
const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description';
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const userPromptBlock = request.prompt?.trim()
@ -838,14 +838,11 @@ ${persistentContext}
Steps (execute in this exact order):
1) TeamCreate create team ${request.teamName}:
- description: ${description}
${step2Block}
${step3Block}
4) After all steps, output a short summary.
3) After all steps, output a short summary.
`;
}
@ -1153,6 +1150,7 @@ export class TeamProvisioningService {
private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS;
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
private inFlightResponses = new Set<string>();
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
private crossTeamSender:
| ((request: {
fromTeam: string;
@ -1193,6 +1191,10 @@ export class TeamProvisioningService {
this.crossTeamSender = sender;
}
setControlApiBaseUrlResolver(resolver: (() => Promise<string | null>) | null): void {
this.controlApiBaseUrlResolver = resolver;
}
getClaudeLogs(
teamName: string,
query?: { offset?: number; limit?: number }
@ -3688,6 +3690,18 @@ export class TeamProvisioningService {
return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name));
}
getRuntimeState(teamName: string): TeamRuntimeState {
const runId = this.getTrackedRunId(teamName);
const run = runId ? (this.runs.get(runId) ?? null) : null;
return {
teamName,
isAlive: this.isTeamAlive(teamName),
runId: run?.runId ?? runId ?? null,
progress: run?.progress ?? null,
};
}
private languageChangeInFlight: Promise<void> = Promise.resolve();
/**
@ -5686,6 +5700,11 @@ export class TeamProvisioningService {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
};
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
if (controlApiBaseUrl) {
env.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl;
}
// SHELL is a Unix concept — only set it on non-Windows platforms.
if (!isWindows) {
env.SHELL = shell;
@ -5729,6 +5748,23 @@ export class TeamProvisioningService {
return { env, authSource: 'none' };
}
private async resolveControlApiBaseUrl(): Promise<string | null> {
if (!this.controlApiBaseUrlResolver) {
return null;
}
try {
return await this.controlApiBaseUrlResolver();
} catch (error) {
logger.warn(
`Failed to resolve team control API base URL: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
}
/**
* Immediately update projectPath in config.json at launch start, before CLI spawn.
* Ensures TeamDetailView shows the correct project path even if provisioning

View file

@ -204,6 +204,7 @@ export const CodeMirrorDiffView = ({
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const endSentinelRef = useRef<HTMLDivElement>(null);
const lastPointerRef = useRef<{ x: number; y: number } | null>(null);
// Local ref to hold externalViewRef for syncing via useEffect
const externalViewRefHolder = useRef(externalViewRef);
@ -475,9 +476,10 @@ export const CodeMirrorDiffView = ({
const chunkRect = chunkEl.getBoundingClientRect();
const btnWidth = btnContainer.offsetWidth || 200;
const margin = 12;
const { style } = btnContainer;
// left is relative to .cm-deletedChunk — so we compute from scroller's right edge
btnContainer.style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`;
btnContainer.style.right = 'auto';
style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`;
style.right = 'auto';
};
// Helper: position a chunkButtons container so it's below the change block,
@ -516,14 +518,48 @@ export const CodeMirrorDiffView = ({
pinToViewportRight(btnContainer, scroller);
};
// Find which chunk index the mouse is directly over, including inline deleted text.
const findHoveredChunkIndex = (event: MouseEvent, view: EditorView): number => {
const el = document.elementFromPoint(event.clientX, event.clientY);
interface RenderedChunkControl {
chunkIndex: number;
chunkEl: HTMLElement;
toolbar: HTMLElement;
}
const getRenderedChunkControls = (view: EditorView): RenderedChunkControl[] => {
const toolbars = view.dom.querySelectorAll<HTMLElement>('.cm-merge-toolbar');
const controls: RenderedChunkControl[] = [];
toolbars.forEach((toolbar) => {
const chunkEl = toolbar.closest<HTMLElement>('.cm-deletedChunk');
if (!chunkEl) return;
const pos = view.posAtDOM(toolbar);
controls.push({
chunkIndex: computeHunkIndexAtPos(view.state, pos),
chunkEl,
toolbar,
});
});
return controls;
};
const resolveChunkIndexFromDeletedChunk = (
deletedChunk: Element,
view: EditorView
): number => {
const toolbar = deletedChunk.querySelector<HTMLElement>('.cm-merge-toolbar');
if (!toolbar) return -1;
return computeHunkIndexAtPos(view.state, view.posAtDOM(toolbar));
};
// Find which chunk index the pointer is directly over, including inline deleted text.
const findHoveredChunkIndex = (
clientX: number,
clientY: number,
view: EditorView
): number => {
const el = document.elementFromPoint(clientX, clientY);
if (!el) return -1;
const deletedChunk = el.closest('.cm-deletedChunk');
if (deletedChunk) {
const all = view.dom.querySelectorAll('.cm-deletedChunk');
return [...all].indexOf(deletedChunk);
return resolveChunkIndexFromDeletedChunk(deletedChunk, view);
}
if (
el.closest(
@ -532,7 +568,7 @@ export const CodeMirrorDiffView = ({
) {
const allChunks = getChunks(view.state);
if (!allChunks) return -1;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
const pos = view.posAtCoords({ x: clientX, y: clientY });
if (pos !== null) {
for (let i = 0; i < allChunks.chunks.length; i++) {
const chunk = allChunks.chunks[i];
@ -543,55 +579,71 @@ export const CodeMirrorDiffView = ({
return -1;
};
// Find chunk nearest to cursor Y (for default "below block" display)
const findNearestChunkIndex = (clientY: number, view: EditorView): number => {
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
let result = -1;
if (allChunkEls.length > 0) {
let bestIdx = 0;
let bestDist = Infinity;
allChunkEls.forEach((el, idx) => {
const rect = el.getBoundingClientRect();
const centerY = (rect.top + rect.bottom) / 2;
const dist = Math.abs(clientY - centerY);
if (dist < bestDist) {
bestDist = dist;
bestIdx = idx;
}
});
result = bestIdx;
const findNearestRenderedChunk = (
clientY: number,
renderedControls: RenderedChunkControl[]
): RenderedChunkControl | null => {
let bestMatch: RenderedChunkControl | null = null;
let bestDist = Infinity;
renderedControls.forEach((control) => {
const rect = control.chunkEl.getBoundingClientRect();
const centerY = (rect.top + rect.bottom) / 2;
const dist = Math.abs(clientY - centerY);
if (dist < bestDist) {
bestDist = dist;
bestMatch = control;
}
});
return bestMatch;
};
const updateActiveToolbar = (
view: EditorView,
clientY: number,
options?: { clientX?: number; followCursor?: boolean }
): void => {
const allChunks = getChunks(view.state);
if (!allChunks || allChunks.chunks.length === 0) return;
const renderedControls = getRenderedChunkControls(view);
if (renderedControls.length === 0) return;
const hoveredChunkIndex =
options?.clientX !== undefined
? findHoveredChunkIndex(options.clientX, clientY, view)
: -1;
const activeControl =
renderedControls.find((control) => control.chunkIndex === hoveredChunkIndex) ??
findNearestRenderedChunk(clientY, renderedControls);
renderedControls.forEach((control) => {
control.toolbar.classList.toggle(
'cm-merge-toolbar-active',
activeControl?.chunkIndex === control.chunkIndex
);
});
if (!activeControl) return;
if (options?.followCursor && hoveredChunkIndex >= 0) {
positionAtCursor(activeControl.chunkEl, clientY, view.scrollDOM);
} else {
positionAtBottom(activeControl.chunkEl, view.scrollDOM);
}
return result;
};
extensions.push(
EditorView.domEventHandlers({
mousemove(event, view) {
const allChunks = getChunks(view.state);
if (allChunks && allChunks.chunks.length > 0) {
const scroller = view.scrollDOM;
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
const hoveredIdx = findHoveredChunkIndex(event, view);
const nearestIdx =
hoveredIdx >= 0 ? hoveredIdx : findNearestChunkIndex(event.clientY, view);
const toolbars = view.dom.querySelectorAll('.cm-merge-toolbar');
toolbars.forEach((tb, idx) => {
tb.classList.toggle('cm-merge-toolbar-active', idx === nearestIdx);
});
if (nearestIdx >= 0 && nearestIdx < allChunkEls.length) {
const chunkEl = allChunkEls[nearestIdx] as HTMLElement;
if (hoveredIdx >= 0) {
positionAtCursor(chunkEl, event.clientY, scroller);
} else {
positionAtBottom(chunkEl, scroller);
}
}
}
lastPointerRef.current = { x: event.clientX, y: event.clientY };
updateActiveToolbar(view, event.clientY, {
clientX: event.clientX,
followCursor: true,
});
return false;
},
mouseleave(_event, view) {
lastPointerRef.current = null;
// Keep active toolbar visible, reposition to "below block"
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
if (activeToolbar) {
@ -601,17 +653,22 @@ export const CodeMirrorDiffView = ({
return false;
},
scroll(_event, view) {
// Reposition active toolbar on horizontal scroll so buttons stay at viewport edge
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
if (activeToolbar) {
const chunkEl = activeToolbar.closest('.cm-deletedChunk');
if (chunkEl) {
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
if (btnContainer) {
pinToViewportRight(btnContainer, view.scrollDOM);
}
}
}
const scrollerRect = view.scrollDOM.getBoundingClientRect();
const pointer = lastPointerRef.current;
const pointerInsideScroller =
pointer &&
pointer.x >= scrollerRect.left &&
pointer.x <= scrollerRect.right &&
pointer.y >= scrollerRect.top &&
pointer.y <= scrollerRect.bottom;
const targetY = pointerInsideScroller
? pointer.y
: (scrollerRect.top + scrollerRect.bottom) / 2;
updateActiveToolbar(view, targetY, {
clientX: pointerInsideScroller ? pointer.x : undefined,
followCursor: Boolean(pointerInsideScroller),
});
return false;
},
})
@ -624,12 +681,8 @@ export const CodeMirrorDiffView = ({
requestAnimationFrame(() => {
const v = update.view;
if (v.dom.querySelector('.cm-merge-toolbar-active')) return;
const first = v.dom.querySelector('.cm-merge-toolbar');
if (first) {
first.classList.add('cm-merge-toolbar-active');
const chunkEl = first.closest('.cm-deletedChunk');
if (chunkEl) positionAtBottom(chunkEl, v.scrollDOM);
}
const scrollerRect = v.scrollDOM.getBoundingClientRect();
updateActiveToolbar(v, (scrollerRect.top + scrollerRect.bottom) / 2);
});
})
);

View file

@ -566,6 +566,13 @@ export interface TeamProvisioningProgress {
assistantOutput?: string;
}
export interface TeamRuntimeState {
teamName: string;
isAlive: boolean;
runId: string | null;
progress: TeamProvisioningProgress | null;
}
export interface GlobalTask extends TeamTaskWithKanban {
teamName: string;
teamDisplayName: string;

View file

@ -0,0 +1,208 @@
import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import { registerTeamRoutes } from '@main/http/teams';
import type { HttpServices } from '@main/http';
import type {
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningProgress,
TeamRuntimeState,
} from '@shared/types/team';
describe('HTTP team runtime routes', () => {
function createServicesMock() {
const launchTeam = vi.fn<
(request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void) => Promise<TeamLaunchResponse>
>();
const getRuntimeState = vi.fn<(teamName: string) => TeamRuntimeState>();
const getProvisioningStatus = vi.fn<(runId: string) => Promise<TeamProvisioningProgress>>();
const stopTeam = vi.fn<(teamName: string) => void>();
const getAliveTeams = vi.fn<() => string[]>();
const services = {
projectScanner: {} as HttpServices['projectScanner'],
sessionParser: {} as HttpServices['sessionParser'],
subagentResolver: {} as HttpServices['subagentResolver'],
chunkBuilder: {} as HttpServices['chunkBuilder'],
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
teamProvisioningService: {
launchTeam,
getRuntimeState,
getProvisioningStatus,
stopTeam,
getAliveTeams,
},
} satisfies HttpServices;
return {
services,
launchTeam,
getRuntimeState,
getProvisioningStatus,
stopTeam,
getAliveTeams,
};
}
async function createApp() {
const app = Fastify();
const mocks = createServicesMock();
registerTeamRoutes(app, mocks.services);
await app.ready();
return { app, ...mocks };
}
it('launches a team with validated request payload', async () => {
const { app, launchTeam } = await createApp();
launchTeam.mockResolvedValue({ runId: 'run-1' });
try {
const response = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/launch',
payload: {
cwd: '/tmp/project',
prompt: 'Resume work',
skipPermissions: false,
clearContext: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ runId: 'run-1' });
expect(launchTeam).toHaveBeenCalledWith(
{
teamName: 'demo-team',
cwd: '/tmp/project',
prompt: 'Resume work',
skipPermissions: false,
clearContext: true,
},
expect.any(Function)
);
} finally {
await app.close();
}
});
it('rejects launch requests with non-absolute cwd', async () => {
const { app, launchTeam } = await createApp();
try {
const response = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/launch',
payload: {
cwd: 'relative/path',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: 'cwd must be an absolute path' });
expect(launchTeam).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it('returns runtime state, provisioning status, and stop results', async () => {
const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } = await createApp();
getRuntimeState
.mockReturnValueOnce({
teamName: 'demo-team',
isAlive: true,
runId: 'run-2',
progress: {
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
})
.mockReturnValueOnce({
teamName: 'demo-team',
isAlive: false,
runId: null,
progress: null,
})
.mockReturnValueOnce({
teamName: 'demo-team',
isAlive: true,
runId: 'run-2',
progress: {
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
});
getProvisioningStatus.mockResolvedValue({
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
});
getAliveTeams.mockReturnValue(['demo-team']);
try {
const runtimeResponse = await app.inject({
method: 'GET',
url: '/api/teams/demo-team/runtime',
});
expect(runtimeResponse.statusCode).toBe(200);
expect(runtimeResponse.json().isAlive).toBe(true);
const provisioningResponse = await app.inject({
method: 'GET',
url: '/api/teams/provisioning/run-2',
});
expect(provisioningResponse.statusCode).toBe(200);
expect(provisioningResponse.json().runId).toBe('run-2');
const stopResponse = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/stop',
});
expect(stopResponse.statusCode).toBe(200);
expect(stopResponse.json()).toEqual({
teamName: 'demo-team',
isAlive: false,
runId: null,
progress: null,
});
expect(stopTeam).toHaveBeenCalledWith('demo-team');
const aliveResponse = await app.inject({
method: 'GET',
url: '/api/teams/runtime/alive',
});
expect(aliveResponse.statusCode).toBe(200);
expect(aliveResponse.json()).toEqual([
{
teamName: 'demo-team',
isAlive: true,
runId: 'run-2',
progress: {
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
},
]);
} finally {
await app.close();
}
});
});