import { addMainBreadcrumb } from '@main/sentry'; import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; import { getAppIconPath } from '@main/utils/appIcon'; import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { stripMarkdown } from '@main/utils/textFormatting'; import { TEAM_ADD_MEMBER, TEAM_ADD_TASK_COMMENT, TEAM_ADD_TASK_RELATIONSHIP, TEAM_ALIVE_LIST, TEAM_CANCEL_PROVISIONING, TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, TEAM_DELETE_DRAFT, TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_MEMBER_SPAWN_STATUSES, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, TEAM_PROCESS_SEND, TEAM_PROVISIONING_PROGRESS, TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_START_TASK_BY_USER, TEAM_STOP, TEAM_TOOL_APPROVAL_READ_FILE, TEAM_TOOL_APPROVAL_RESPOND, TEAM_TOOL_APPROVAL_SETTINGS, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, TEAM_UPDATE_MEMBER_ROLE, 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 { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { extractFlagsFromHelp, extractUserFlags, PROTECTED_CLI_FLAGS, } from '@shared/utils/cliArgsParser'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { buildStandaloneSlashCommandMeta, parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; import crypto from 'crypto'; import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import { ConfigManager } from '../services/infrastructure/ConfigManager'; import { NotificationManager } from '../services/infrastructure/NotificationManager'; import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver'; import { buildActionModeAgentBlock, isAgentActionMode, } from '../services/team/actionModeInstructions'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { buildReplaceMembersDiff, buildReplaceMembersSummaryMessage, } from '../services/team/memberUpdateNotifications'; import { validateFromField, validateMemberName, validateTaskId, validateTeammateName, validateTeamName, } from './guards'; import type { BranchStatusService, MemberStatsComputer, TeamDataService, TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, } from '../services'; import type { TeamBackupService } from '../services/team/TeamBackupService'; import type { AddTaskCommentRequest, AgentActionMode, AttachmentFileData, AttachmentMeta, AttachmentPayload, CreateTaskRequest, EffortLevel, GlobalTask, IpcResult, KanbanColumnId, LeadActivitySnapshot, LeadContextUsage, LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, TaskComment, TaskRef, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, TeamData, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, ToolApprovalFileContent, ToolApprovalSettings, UpdateKanbanPatch, } from '@shared/types'; import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; const logger = createLogger('IPC:teams'); /** * In-memory set of rate-limit message keys already processed. * Independent of NotificationManager storage — survives notification deletion/pruning. * Without this, deleted rate-limit notifications would re-appear on next getData() scan. */ const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; /** * In-memory set of API error message keys already processed. * Independent of NotificationManager storage — survives notification deletion/pruning. */ const seenApiErrorKeys = new Set(); const SEEN_API_ERROR_KEYS_MAX = 500; /** * Check messages for rate limit indicators and fire notifications for new ones. * Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion) * and NotificationManager dedupeKey (to prevent storage duplicates). */ function checkRateLimitMessages( messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], teamName: string, teamDisplayName: string, projectPath?: string ): void { for (const msg of messages) { if (msg.from === 'user') continue; if (!isRateLimitMessage(msg.text)) continue; const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; const dedupeKey = `rate-limit:${teamName}:${rawKey}`; // In-memory guard: prevents resurrection after user deletes the notification if (seenRateLimitKeys.has(dedupeKey)) continue; seenRateLimitKeys.add(dedupeKey); // Evict oldest entries to prevent unbounded growth if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { const first = seenRateLimitKeys.values().next().value; if (first) seenRateLimitKeys.delete(first); } void NotificationManager.getInstance() .addTeamNotification({ teamEventType: 'rate_limit', teamName, teamDisplayName, from: msg.from, summary: `Rate limit: ${msg.from}`, body: msg.text.slice(0, 200), dedupeKey, projectPath, }) .catch(() => undefined); } } /** * Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications. * Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey. * Skips rate-limit messages (they have their own notification path). */ function checkApiErrorMessages( messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], teamName: string, teamDisplayName: string, projectPath?: string ): void { for (const msg of messages) { if (msg.from === 'user') continue; if (!isApiErrorMessage(msg.text)) continue; // Don't double-notify if it's also a rate limit message if (isRateLimitMessage(msg.text)) continue; const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; const dedupeKey = `api-error:${teamName}:${rawKey}`; if (seenApiErrorKeys.has(dedupeKey)) continue; seenApiErrorKeys.add(dedupeKey); if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) { const first = seenApiErrorKeys.values().next().value; if (first) seenApiErrorKeys.delete(first); } // Extract status code for summary const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text); const statusCode = statusMatch?.[1] ?? '???'; void NotificationManager.getInstance() .addTeamNotification({ teamEventType: 'rate_limit', // reuse rate_limit type — closest fit teamName, teamDisplayName, from: msg.from, summary: `API Error ${statusCode}: ${msg.from}`, body: msg.text.slice(0, 400), dedupeKey, projectPath, }) .catch(() => undefined); } } let teamDataService: TeamDataService | null = null; let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; let branchStatusService: BranchStatusService | null = null; const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); const teamMetaStore = new TeamMetaStore(); const ALLOWED_ATTACHMENT_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', ]); const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file /** * Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification. * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html */ const activeTeamNotifications = new Set(); const MAX_ATTACHMENTS = 5; const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total export function initializeTeamHandlers( service: TeamDataService, provisioningService: TeamProvisioningService, logsFinder?: TeamMemberLogsFinder, statsComputer?: MemberStatsComputer, backupService?: TeamBackupService, toolTracker?: TeammateToolTracker, branchTracker?: BranchStatusService ): void { teamDataService = service; teamProvisioningService = provisioningService; teamMemberLogsFinder = logsFinder ?? null; memberStatsComputer = statsComputer ?? null; teamBackupService = backupService ?? null; teammateToolTracker = toolTracker ?? null; branchStatusService = branchTracker ?? null; } export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking); ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus); ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); ipcMain.handle(TEAM_UPDATE_KANBAN_COLUMN_ORDER, handleUpdateKanbanColumnOrder); ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus); ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner); ipcMain.handle(TEAM_UPDATE_TASK_FIELDS, handleUpdateTaskFields); ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam); ipcMain.handle(TEAM_RESTORE, handleRestoreTeam); ipcMain.handle(TEAM_PERMANENTLY_DELETE, handlePermanentlyDeleteTeam); ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend); ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive); ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList); ipcMain.handle(TEAM_STOP, handleStopTeam); ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); ipcMain.handle(TEAM_START_TASK_BY_USER, handleStartTaskByUser); ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks); ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember); ipcMain.handle(TEAM_REPLACE_MEMBERS, handleReplaceMembers); ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember); ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole); ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch); ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments); 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); ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification); ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification); ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship); ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship); ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment); ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment); ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment); ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond); ipcMain.handle(TEAM_TOOL_APPROVAL_READ_FILE, handleToolApprovalReadFile); ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs); ipcMain.handle(TEAM_TOOL_APPROVAL_SETTINGS, handleToolApprovalSettings); ipcMain.handle(TEAM_GET_SAVED_REQUEST, handleGetSavedRequest); ipcMain.handle(TEAM_DELETE_DRAFT, handleDeleteDraft); logger.info('Team handlers registered'); } export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING); ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_LAUNCH); ipcMain.removeHandler(TEAM_PROVISIONING_STATUS); ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); ipcMain.removeHandler(TEAM_SEND_MESSAGE); ipcMain.removeHandler(TEAM_CREATE_TASK); ipcMain.removeHandler(TEAM_REQUEST_REVIEW); ipcMain.removeHandler(TEAM_UPDATE_KANBAN); ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER); ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS); ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER); ipcMain.removeHandler(TEAM_UPDATE_TASK_FIELDS); ipcMain.removeHandler(TEAM_DELETE_TEAM); ipcMain.removeHandler(TEAM_RESTORE); ipcMain.removeHandler(TEAM_PERMANENTLY_DELETE); ipcMain.removeHandler(TEAM_PROCESS_SEND); ipcMain.removeHandler(TEAM_PROCESS_ALIVE); ipcMain.removeHandler(TEAM_ALIVE_LIST); ipcMain.removeHandler(TEAM_STOP); ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); ipcMain.removeHandler(TEAM_START_TASK_BY_USER); ipcMain.removeHandler(TEAM_GET_ALL_TASKS); ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_MEMBER); ipcMain.removeHandler(TEAM_REPLACE_MEMBERS); ipcMain.removeHandler(TEAM_REMOVE_MEMBER); ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE); ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH); ipcMain.removeHandler(TEAM_GET_ATTACHMENTS); 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); ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION); ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION); ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP); ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP); ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND); ipcMain.removeHandler(TEAM_TOOL_APPROVAL_READ_FILE); ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS); ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS); ipcMain.removeHandler(TEAM_GET_SAVED_REQUEST); ipcMain.removeHandler(TEAM_DELETE_DRAFT); } function getTeamDataService(): TeamDataService { if (!teamDataService) { throw new Error('Team handlers are not initialized'); } return teamDataService; } function getTeamProvisioningService(): TeamProvisioningService { if (!teamProvisioningService) { throw new Error('Team provisioning handlers are not initialized'); } return teamProvisioningService; } function getTeammateToolTracker(): TeammateToolTracker { if (!teammateToolTracker) { throw new Error('Teammate tool tracker is not initialized'); } return teammateToolTracker; } function getBranchStatusService(): BranchStatusService { if (!branchStatusService) { throw new Error('Branch status service is not initialized'); } return branchStatusService; } async function wrapTeamHandler( 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(`[teams:${operation}] ${message}`); return { success: false, error: message }; } } async function handleGetProjectBranch( _event: IpcMainInvokeEvent, projectPath: unknown ): Promise> { if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { return { success: false, error: 'projectPath must be a non-empty string' }; } try { const branch = await gitIdentityResolver.getBranch(path.normalize(projectPath.trim())); return { success: true, data: branch }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error(`[teams:getProjectBranch] ${message}`); return { success: false, error: message }; } } async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { setCurrentMainOp('team:list'); const startedAt = Date.now(); try { return await wrapTeamHandler('list', () => getTeamDataService().listTeams()); } finally { const ms = Date.now() - startedAt; if (ms >= 1500) { logger.warn(`[teams:list] slow ms=${ms}`); } setCurrentMainOp(null); } } async function handleGetData( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } const tn = validated.value!; const startedAt = Date.now(); let data: TeamData; setCurrentMainOp('team:getData'); try { try { data = await getTeamDataService().getTeamData(tn); } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( message === `Team not found: ${tn}` && getTeamProvisioningService().hasProvisioningRun(tn) ) { return { success: false, error: 'TEAM_PROVISIONING' }; } // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate) if (message === `Team not found: ${tn}`) { const meta = await teamMetaStore.getMeta(tn); if (meta) { return { success: false, error: 'TEAM_DRAFT' }; } } logger.error(`[teams:getData] ${message}`); return { success: false, error: message }; } } finally { setCurrentMainOp(null); } const getDataMs = Date.now() - startedAt; if (getDataMs >= 1500) { logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); } const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); const displayName = data.config.name || tn; const projectPath = data.config.projectPath; const live = provisioning.getLiveLeadProcessMessages(tn); if (live.length === 0) { checkRateLimitMessages(data.messages, tn, displayName, projectPath); checkApiErrorMessages(data.messages, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive } }; } 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 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; if (!isLeadThoughtLike(msg)) continue; existingTextFingerprints.add(getLeadThoughtFingerprint(msg)); } const keyFor = (m: { messageId?: string; timestamp: string; from: string; text: string; }): string => { if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { return m.messageId; } return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; }; // 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(); // Content-based dedup for SendMessage captures: Claude Code CLI and our // persistInboxMessage both write to inboxes/{member}.json, producing two entries // with identical content but different messageIds. Track content fingerprints // (from+to+text) with timestamps to collapse them within a 5-second window. const contentSeen = new Map(); // fingerprint → timestamp ms 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 = getLeadThoughtFingerprint(msg); // Skip if the same thought already exists in persisted history for the same session. if (existingTextFingerprints.has(fp)) { continue; } // Dedup live lead_process thoughts with the same text in the same session. if (leadProcessTextFingerprints.has(fp)) { continue; } leadProcessTextFingerprints.add(fp); } // Content dedup for directed messages (SendMessage captures): // same from+to+text within 5 seconds = duplicate from CLI + our persist. if (typeof msg.to === 'string' && msg.to.trim().length > 0) { const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`; const msgMs = Date.parse(msg.timestamp); const existingMs = contentSeen.get(contentFp); if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) { continue; // duplicate within 5s window — skip } contentSeen.set(contentFp, msgMs); } const key = keyFor(msg); if (seen.has(key)) continue; seen.add(key); merged.push(msg); } merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); checkRateLimitMessages(merged, tn, displayName, projectPath); checkApiErrorMessages(merged, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive, messages: merged } }; } async function handleGetTaskChangePresence( _event: IpcMainInvokeEvent, teamName: unknown ): Promise>> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('getTaskChangePresence', () => getTeamDataService().getTaskChangePresence(validated.value!) ); } async function handleSetChangePresenceTracking( _event: IpcMainInvokeEvent, teamName: unknown, enabled: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } if (typeof enabled !== 'boolean') { return { success: false, error: 'enabled must be a boolean' }; } return wrapTeamHandler('setChangePresenceTracking', async () => { getTeamDataService().setTaskChangePresenceTracking(validated.value!, enabled); }); } async function handleSetProjectBranchTracking( _event: IpcMainInvokeEvent, projectPath: unknown, enabled: unknown ): Promise> { if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { return { success: false, error: 'projectPath must be a non-empty string' }; } if (typeof enabled !== 'boolean') { return { success: false, error: 'enabled must be a boolean' }; } return wrapTeamHandler('setProjectBranchTracking', async () => { await getBranchStatusService().setTracking(projectPath.trim(), enabled); }); } async function handleSetToolActivityTracking( _event: IpcMainInvokeEvent, teamName: unknown, enabled: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } if (typeof enabled !== 'boolean') { return { success: false, error: 'enabled must be a boolean' }; } return wrapTeamHandler('setToolActivityTracking', async () => { await getTeammateToolTracker().setTracking(validated.value!, enabled); }); } async function handleDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('deleteTeam', async () => { getTeamProvisioningService().stopTeam(validated.value!); await getTeamDataService().deleteTeam(validated.value!); }); } async function handleRestoreTeam( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('restoreTeam', () => getTeamDataService().restoreTeam(validated.value!)); } async function handlePermanentlyDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('permanentlyDeleteTeam', async () => { await getTeamDataService().permanentlyDeleteTeam(validated.value!); // Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/ const appData = getAppDataPath(); await fs.promises .rm(path.join(appData, 'attachments', validated.value!), { recursive: true, force: true }) .catch(() => undefined); await fs.promises .rm(path.join(appData, 'task-attachments', validated.value!), { recursive: true, force: true, }) .catch(() => undefined); // Mark in backup registry AFTER successful deletion if (teamBackupService) { await teamBackupService.markDeletedByUser(validated.value!); } }); } async function handleUpdateConfig( _event: IpcMainInvokeEvent, teamName: unknown, updates: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } if (!updates || typeof updates !== 'object') { return { success: false, error: 'Invalid updates object' }; } const { name, description, color } = updates as TeamUpdateConfigRequest; if (name !== undefined && typeof name !== 'string') { return { success: false, error: 'name must be a string' }; } if (description !== undefined && typeof description !== 'string') { return { success: false, error: 'description must be a string' }; } if (color !== undefined && typeof color !== 'string') { return { success: false, error: 'color must be a string' }; } return wrapTeamHandler('updateConfig', async () => { const tn = validated.value!; const result = await getTeamDataService().updateConfig(tn, { name, description, color, }); if (!result) { throw new Error('Team config not found'); } // Notify running lead about the rename so it stays aware of current team name if (typeof name === 'string' && name.trim()) { const provisioning = getTeamProvisioningService(); if (provisioning.isTeamAlive(tn)) { const msg = `The team has been renamed to "${name.trim()}". Please use this name when referring to the team going forward.`; try { await provisioning.sendMessageToTeam(tn, msg); } catch { logger.warn(`Failed to notify lead about team rename for ${tn}`); } } } return result; }); } function isProvisioningTeamName(teamName: string): boolean { if (teamName.length > 64) return false; const parts = teamName.split('-'); return parts.every((p) => /^[a-z0-9]+$/.test(p)); } const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high']; function isValidEffort(value: unknown): value is EffortLevel { return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value); } function parseOptionalMemberProviderId( value: unknown ): | { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined } | { valid: false; error: string } { if (value === undefined || value === null || value === '') { return { valid: true, value: undefined }; } if (value === 'anthropic' || value === 'codex' || value === 'gemini') { return { valid: true, value }; } return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' }; } function parseOptionalMemberEffort( value: unknown ): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } { if (value === undefined || value === null || value === '') { return { valid: true, value: undefined }; } if (isValidEffort(value)) { return { valid: true, value }; } return { valid: false, error: 'member effort must be low, medium, or high' }; } async function validateProvisioningRequest( request: unknown ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { if (!request || typeof request !== 'object') { return { valid: false, error: 'Invalid team create request' }; } const payload = request as Partial; if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) { return { valid: false, error: 'teamName is required' }; } const teamName = payload.teamName.trim(); if (!isProvisioningTeamName(teamName)) { return { valid: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' }; } if (payload.displayName !== undefined && typeof payload.displayName !== 'string') { return { valid: false, error: 'displayName must be string' }; } if (payload.description !== undefined && typeof payload.description !== 'string') { return { valid: false, error: 'description must be string' }; } if (!Array.isArray(payload.members)) { return { valid: false, error: 'members must be an array' }; } const seenNames = new Set(); const members: TeamCreateRequest['members'] = []; for (const member of payload.members) { if (!member || typeof member !== 'object') { return { valid: false, error: 'member must be object' }; } const nameValidation = validateTeammateName((member as { name?: unknown }).name); if (!nameValidation.valid) { return { valid: false, error: nameValidation.error ?? 'Invalid member name' }; } const memberName = nameValidation.value!; if (seenNames.has(memberName)) { return { valid: false, error: 'member names must be unique' }; } seenNames.add(memberName); const role = (member as { role?: unknown }).role; if (role !== undefined && typeof role !== 'string') { return { valid: false, error: 'member role must be string' }; } const workflow = (member as { workflow?: unknown }).workflow; if (workflow !== undefined && typeof workflow !== 'string') { return { valid: false, error: 'member workflow must be string' }; } const providerValidation = parseOptionalMemberProviderId( (member as { providerId?: unknown }).providerId ); if (!providerValidation.valid) { return { valid: false, error: providerValidation.error }; } const model = (member as { model?: unknown }).model; if (model !== undefined && typeof model !== 'string') { return { valid: false, error: 'member model must be string' }; } members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined, workflow: typeof workflow === 'string' ? workflow.trim() : undefined, providerId: providerValidation.value, model: typeof model === 'string' ? model.trim() || undefined : undefined, }); } if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) { return { valid: false, error: 'cwd is required' }; } const cwd = payload.cwd.trim(); if (!path.isAbsolute(cwd)) { return { valid: false, error: 'cwd must be an absolute path' }; } if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { return { valid: false, error: 'prompt must be a string' }; } try { await fs.promises.mkdir(cwd, { recursive: true }); } catch { return { valid: false, error: 'failed to create cwd directory' }; } let stat: fs.Stats; try { stat = await fs.promises.stat(cwd); } catch { return { valid: false, error: 'cwd does not exist' }; } if (!stat.isDirectory()) { 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: { teamName, displayName: payload.displayName?.trim() || undefined, description: payload.description?.trim() || undefined, color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined, members, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, providerId: payload.providerId === 'codex' ? 'codex' : payload.providerId === 'gemini' ? 'gemini' : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, 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, }, }; } async function handleGetClaudeLogs( _event: IpcMainInvokeEvent, teamName: unknown, query?: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } let parsed: TeamClaudeLogsQuery | undefined; if (query !== undefined) { if (!query || typeof query !== 'object') { return { success: false, error: 'query must be an object' }; } const q = query as Record; parsed = { offset: typeof q.offset === 'number' ? q.offset : undefined, limit: typeof q.limit === 'number' ? q.limit : undefined, }; } return wrapTeamHandler('getClaudeLogs', async () => { const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed); return { lines: data.lines, total: data.total, hasMore: data.hasMore, updatedAt: data.updatedAt, }; }); } async function handleCreateTeam( event: IpcMainInvokeEvent, request: unknown ): Promise> { const validation = await validateProvisioningRequest(request); if (!validation.valid) { return { success: false, error: validation.error }; } return wrapTeamHandler('create', () => { addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName }); return getTeamProvisioningService().createTeam(validation.value, (progress) => { try { event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn(`Failed to emit provisioning progress: ${message}`); } }); }); } async function handleLaunchTeam( event: IpcMainInvokeEvent, request: unknown ): Promise> { if (!request || typeof request !== 'object') { return { success: false, error: 'Invalid team launch request' }; } const payload = request as Partial; const validatedTeamName = validateTeamName(payload.teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) { return { success: false, error: 'cwd is required' }; } const cwd = payload.cwd.trim(); if (!path.isAbsolute(cwd)) { return { success: false, error: 'cwd must be an absolute path' }; } try { const stat = await fs.promises.stat(cwd); if (!stat.isDirectory()) { return { success: false, error: 'cwd must be a directory' }; } } catch { return { success: false, error: 'cwd does not exist' }; } if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { return { success: false, error: 'prompt must be a string' }; } if (payload.model !== undefined && typeof payload.model !== 'string') { return { success: false, error: 'model must be a string' }; } // Detect draft team: team.meta.json exists but config.json doesn't. // This happens when user created team config without launching (launchTeam=false), // or when provisioning failed before TeamCreate could run. // Redirect to createTeam so TeamCreate runs properly. const tn = validatedTeamName.value!; const configPath = path.join(getTeamsBasePath(), tn, 'config.json'); let isDraft = false; try { await fs.promises.access(configPath, fs.constants.F_OK); } catch { const meta = await teamMetaStore.getMeta(tn); if (meta) isDraft = true; } if (isDraft) { const meta = await teamMetaStore.getMeta(tn); const membersStore = new TeamMembersMetaStore(); const members = await membersStore.getMembers(tn); const createRequest: TeamCreateRequest = { teamName: tn, displayName: meta?.displayName, description: meta?.description, color: meta?.color, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, providerId: payload.providerId === 'codex' ? 'codex' : payload.providerId === 'gemini' ? 'gemini' : meta?.providerId === 'codex' ? 'codex' : meta?.providerId === 'gemini' ? 'gemini' : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : 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, members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow, providerId: m.providerId, model: m.model, effort: m.effort, })), }; return wrapTeamHandler('create', () => getTeamProvisioningService().createTeam(createRequest, (progress) => { try { event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn(`Failed to emit draft launch provisioning progress: ${message}`); } }) ); } return wrapTeamHandler('launch', () => { addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! }); return getTeamProvisioningService().launchTeam( { teamName: validatedTeamName.value!, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, providerId: payload.providerId === 'codex' ? 'codex' : payload.providerId === 'gemini' ? 'gemini' : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, 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 { event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn(`Failed to emit launch provisioning progress: ${message}`); } } ); }); } 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, providerId: unknown, providerIds: unknown ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; } validatedCwd = cwd.trim(); if (!path.isAbsolute(validatedCwd)) { return { success: false, error: 'cwd must be an absolute path' }; } } if (providerId !== undefined) { if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') { return { success: false, error: 'providerId must be anthropic, codex, or gemini' }; } validatedProviderId = providerId; } if (providerIds !== undefined) { if (!Array.isArray(providerIds)) { return { success: false, error: 'providerIds must be an array when provided' }; } const normalized: Array<'anthropic' | 'codex' | 'gemini'> = []; for (const entry of providerIds) { if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; } if (!normalized.includes(entry)) { normalized.push(entry); } } validatedProviderIds = normalized; } return wrapTeamHandler('prepareProvisioning', () => getTeamProvisioningService().prepareForProvisioning(validatedCwd, { providerId: validatedProviderId, providerIds: validatedProviderIds, }) ); } async function handleProvisioningStatus( _event: IpcMainInvokeEvent, runId: unknown ): Promise> { if (typeof runId !== 'string' || runId.trim().length === 0) { return { success: false, error: 'runId is required' }; } return wrapTeamHandler('provisioningStatus', () => getTeamProvisioningService().getProvisioningStatus(runId.trim()) ); } async function handleCancelProvisioning( _event: IpcMainInvokeEvent, runId: unknown ): Promise> { if (typeof runId !== 'string' || runId.trim().length === 0) { return { success: false, error: 'runId is required' }; } return wrapTeamHandler('cancelProvisioning', () => getTeamProvisioningService().cancelProvisioning(runId.trim()) ); } function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch { if (!value || typeof value !== 'object') { return false; } const patch = value as Partial & { op?: unknown; column?: unknown }; if (patch.op === 'remove') { return true; } if (patch.op === 'request_changes') { return ( (patch.comment === undefined || typeof patch.comment === 'string') && validateTaskRefs((patch as { taskRefs?: unknown }).taskRefs).valid ); } return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved'); } function validateTaskRefs( value: unknown ): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } { if (value === undefined) { return { valid: true, value: undefined }; } if (!Array.isArray(value)) { return { valid: false, error: 'taskRefs must be an array' }; } const taskRefs: TaskRef[] = []; for (const entry of value) { if (!entry || typeof entry !== 'object') { return { valid: false, error: 'taskRefs entries must be objects' }; } const row = entry as Partial; const taskId = typeof row.taskId === 'string' ? row.taskId.trim() : ''; const displayId = typeof row.displayId === 'string' ? row.displayId.trim() : ''; const teamName = typeof row.teamName === 'string' ? row.teamName.trim() : ''; if (!taskId || !displayId || !teamName) { return { valid: false, error: 'Each taskRef must include taskId, displayId, and teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { valid: false, error: validatedTaskId.error ?? 'Invalid taskRef taskId' }; } const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { valid: false, error: validatedTeamName.error ?? 'Invalid taskRef teamName' }; } taskRefs.push({ taskId: validatedTaskId.value!, displayId, teamName: validatedTeamName.value!, }); } return { valid: true, value: taskRefs }; } async function handleGetAttachments( _event: IpcMainInvokeEvent, teamName: unknown, messageId: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; if (typeof messageId !== 'string' || messageId.trim().length === 0) { return { success: false, error: 'messageId must be a non-empty string' }; } const safeMessageId = messageId.trim(); if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) { return { success: false, error: 'Invalid messageId' }; } return wrapTeamHandler('getAttachments', () => attachmentStore.getAttachments(vTeam.value!, safeMessageId) ); } function validateAttachments( attachments: unknown ): { valid: true; value: AttachmentPayload[] } | { valid: false; error: string } { if (!Array.isArray(attachments)) { return { valid: false, error: 'attachments must be an array' }; } if (attachments.length > MAX_ATTACHMENTS) { return { valid: false, error: `Maximum ${MAX_ATTACHMENTS} attachments allowed` }; } let totalSize = 0; const result: AttachmentPayload[] = []; for (const att of attachments) { if (!att || typeof att !== 'object') { return { valid: false, error: 'Invalid attachment entry' }; } const a = att as Partial; if (typeof a.id !== 'string' || typeof a.filename !== 'string') { return { valid: false, error: 'Attachment must have id and filename' }; } if (typeof a.data !== 'string' || typeof a.mimeType !== 'string') { return { valid: false, error: 'Attachment must have data and mimeType' }; } if (typeof a.size !== 'number' || a.size <= 0) { return { valid: false, error: 'Attachment must have a positive size' }; } if (!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)) { return { valid: false, error: `Unsupported attachment type: ${a.mimeType}` }; } if (a.size > MAX_ATTACHMENT_SIZE) { return { valid: false, error: `Attachment "${a.filename}" exceeds 10MB limit` }; } // Sanity check: base64 data should be roughly 4/3 of the reported binary size const estimatedBinarySize = Math.ceil(a.data.length * 0.75); if (estimatedBinarySize > MAX_ATTACHMENT_SIZE * 1.1) { return { valid: false, error: `Attachment "${a.filename}" data exceeds size limit` }; } totalSize += Math.max(a.size, estimatedBinarySize); result.push({ id: a.id, filename: a.filename, data: a.data, mimeType: a.mimeType, size: a.size, }); } if (totalSize > MAX_TOTAL_ATTACHMENT_SIZE) { return { valid: false, error: 'Total attachment size exceeds 20MB limit' }; } 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, request: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } if (!request || typeof request !== 'object') { return { success: false, error: 'Invalid send message request' }; } const payload = request as Partial; const validatedMember = validateMemberName(payload.member); if (!validatedMember.valid) { return { success: false, error: validatedMember.error ?? 'Invalid member' }; } if (typeof payload.text !== 'string' || payload.text.trim().length === 0) { return { success: false, error: 'text must be non-empty string' }; } if (payload.summary !== undefined && typeof payload.summary !== 'string') { return { success: false, error: 'summary must be string' }; } if (payload.from !== undefined) { const validatedFrom = validateFromField(payload.from); if (!validatedFrom.valid) { 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' }; } const validatedTaskRefs = validateTaskRefs(payload.taskRefs); if (!validatedTaskRefs.valid) { return { success: false, error: validatedTaskRefs.error }; } let validatedAttachments: AttachmentPayload[] | undefined; if ( payload.attachments !== undefined && Array.isArray(payload.attachments) && payload.attachments.length > 0 ) { const attResult = validateAttachments(payload.attachments); if (!attResult.valid) { return { success: false, error: attResult.error }; } 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 provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); 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)) { throw new Error( 'Attachments are only supported when sending to the team lead while the team is online' ); } // Smart routing: lead + alive → stdin direct, else → inbox if (isLeadRecipient && isAlive) { const resolvedLeadName = leadName ?? memberName; // Pre-generate stable messageId so both stdin and persistence use the same identity. // This allows the lead to call task_create_from_message with the exact messageId. const preGeneratedMessageId = crypto.randomUUID(); // Separate try blocks: stdin delivery vs persistence // If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate) const standaloneSlashCommand = !validatedAttachments?.length ? parseStandaloneSlashCommand(payload.text!) : null; const slashCommandMeta = standaloneSlashCommand ? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw) : null; const rawSlashCommandText = standaloneSlashCommand?.raw; const stdinTextForLead = rawSlashCommandText ? rawSlashCommandText : [ `You received a direct message from the user.`, `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.`, AGENT_BLOCK_OPEN, `MessageId: ${preGeneratedMessageId}`, `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, AGENT_BLOCK_CLOSE, ``, `Message from user:`, buildMessageDeliveryText(payload.text!, { actionMode, isLeadRecipient: true, }), ].join('\n'); const persistTextForLead = rawSlashCommandText ?? payload.text!; let stdinSent = false; try { await provisioning.sendMessageToTeam( tn, stdinTextForLead, rawSlashCommandText ? undefined : validatedAttachments ); stdinSent = true; } catch (stdinError: unknown) { // Stdin failed (process died between check and write) // If attachments were requested, fail rather than silently dropping them if (validatedAttachments?.length) { throw new Error( 'Failed to deliver message with attachments: team process became unavailable' ); } const errMsg = stdinError instanceof Error ? stdinError.message : 'unknown error'; logger.warn(`stdin fallback for ${tn}: ${errMsg}`); // Fallback to inbox path below } if (stdinSent) { // Save attachment files to disk FIRST to get file paths for metadata let attachmentFilePaths: Map | undefined; if (validatedAttachments?.length) { try { attachmentFilePaths = await attachmentStore.saveAttachments( tn, preGeneratedMessageId, validatedAttachments ); } catch (e) { logger.warn(`Failed to save attachments: ${e}`); } } const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => { const fp = attachmentFilePaths?.get(a.id); return { id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size, ...(fp ? { filePath: fp } : {}), }; }); // Persistence is best-effort — stdin already delivered the message let result: SendMessageResult; try { result = await getTeamDataService().sendDirectToLead( tn, resolvedLeadName, persistTextForLead, payload.summary, attachmentMeta, validatedTaskRefs.value, preGeneratedMessageId ); } catch (persistError) { logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); result = { deliveredToInbox: false, messageId: preGeneratedMessageId }; } // Attachment files already saved above (before metadata construction) provisioning.pushLiveLeadProcessMessage(tn, { from: 'user', to: resolvedLeadName, text: persistTextForLead, timestamp: new Date().toISOString(), read: true, summary: payload.summary, messageId: result.messageId, source: 'user_sent', attachments: attachmentMeta, taskRefs: validatedTaskRefs.value, ...(slashCommandMeta ? { messageKind: 'slash_command' as const, slashCommand: slashCommandMeta, } : {}), }); return result; } } // Inbox path: offline lead or regular members (no attachment support) const baseText = payload.text!.trim(); const memberDeliveryText = buildMessageDeliveryText(baseText, { actionMode, isLeadRecipient, }); const result = await getTeamDataService().sendMessage(tn, { member: memberName, text: memberDeliveryText, summary: payload.summary, from: payload.from, source: 'user_sent', taskRefs: validatedTaskRefs.value, }); // Teammate inbox relay DISABLED (2026-03-23). // Teammates read their own inbox files directly via fs.watch — confirmed empirically. // Relaying through the lead (relayMemberInboxMessages) caused multiple bugs: // 1. Lead responded to user instead of forwarding to the teammate // 2. Duplicate messages (relay loop: markInboxMessagesRead → FileWatcher → relay again) // 3. Fragile LLM-dependent prompt chain for routing // The message is already persisted in inboxes/{member}.json above — that's sufficient. // Teammate responses go to inboxes/user.json and are read by TeamInboxReader. // Lead relay (relayLeadInboxMessages) is still needed — lead reads stdin only, not inbox. // // if (!isLeadRecipient && isAlive) { // try { // await provisioning.relayMemberInboxMessages(tn, memberName); // } catch (e: unknown) { // logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`); // } // } // Best-effort relay for lead via inbox if (isLeadRecipient && isAlive) { void provisioning .relayLeadInboxMessages(tn) .catch((e: unknown) => logger.warn(`Relay after sendMessage failed for ${tn}: ${String(e)}`) ); } return result; }); } async function handleCreateTask( _event: IpcMainInvokeEvent, teamName: unknown, request: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } if (!request || typeof request !== 'object') { return { success: false, error: 'Invalid create task request' }; } const payload = request as Partial; if (typeof payload.subject !== 'string' || payload.subject.trim().length === 0) { return { success: false, error: 'subject must be a non-empty string' }; } if (payload.subject.trim().length > 500) { return { success: false, error: 'subject exceeds max length (500)' }; } if (payload.description !== undefined && typeof payload.description !== 'string') { return { success: false, error: 'description must be string' }; } const validatedDescriptionTaskRefs = validateTaskRefs(payload.descriptionTaskRefs); if (!validatedDescriptionTaskRefs.valid) { return { success: false, error: validatedDescriptionTaskRefs.error }; } if (payload.owner !== undefined) { const validatedOwner = validateMemberName(payload.owner); if (!validatedOwner.valid) { return { success: false, error: validatedOwner.error ?? 'Invalid owner' }; } } if (payload.blockedBy !== undefined) { if ( !Array.isArray(payload.blockedBy) || payload.blockedBy.some((id) => typeof id !== 'string') ) { return { success: false, error: 'blockedBy must be an array of task ID strings' }; } } if (payload.related !== undefined) { if (!Array.isArray(payload.related) || payload.related.some((id) => typeof id !== 'string')) { return { success: false, error: 'related must be an array of task ID strings' }; } for (const id of payload.related) { const validated = validateTaskId(id); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid related task id' }; } } } if (payload.prompt !== undefined) { if (typeof payload.prompt !== 'string') { return { success: false, error: 'prompt must be a string' }; } if (payload.prompt.length > 5000) { return { success: false, error: 'prompt exceeds max length (5000)' }; } } const validatedPromptTaskRefs = validateTaskRefs(payload.promptTaskRefs); if (!validatedPromptTaskRefs.valid) { return { success: false, error: validatedPromptTaskRefs.error }; } if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') { return { success: false, error: 'startImmediately must be a boolean' }; } return wrapTeamHandler('createTask', () => getTeamDataService().createTask(validatedTeamName.value!, { subject: payload.subject!.trim(), description: payload.description?.trim(), owner: payload.owner?.trim() || undefined, blockedBy: payload.blockedBy, related: payload.related, descriptionTaskRefs: validatedDescriptionTaskRefs.value, prompt: payload.prompt?.trim() || undefined, promptTaskRefs: validatedPromptTaskRefs.value, startImmediately: payload.startImmediately, }) ); } async function handleRequestReview( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } return wrapTeamHandler('requestReview', () => getTeamDataService().requestReview(validatedTeamName.value!, validatedTaskId.value!) ); } async function handleUpdateKanban( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, patch: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } if (!isUpdateKanbanPatch(patch)) { return { success: false, error: 'Invalid kanban patch' }; } return wrapTeamHandler('updateKanban', async () => { await getTeamDataService().updateKanban( validatedTeamName.value!, validatedTaskId.value!, patch ); }); } function validateKanbanColumnId( value: unknown ): { valid: true; value: KanbanColumnId } | { valid: false; error: string } { if (typeof value !== 'string' || !KANBAN_COLUMN_IDS.includes(value as KanbanColumnId)) { return { valid: false, error: `columnId must be one of: ${KANBAN_COLUMN_IDS.join(', ')}` }; } return { valid: true, value: value as KanbanColumnId }; } async function handleUpdateKanbanColumnOrder( _event: IpcMainInvokeEvent, teamName: unknown, columnId: unknown, orderedTaskIds: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedColumnId = validateKanbanColumnId(columnId); if (!validatedColumnId.valid) { return { success: false, error: validatedColumnId.error ?? 'Invalid columnId' }; } if (!Array.isArray(orderedTaskIds)) { return { success: false, error: 'orderedTaskIds must be an array' }; } const ids = orderedTaskIds.filter((id): id is string => typeof id === 'string'); return wrapTeamHandler('updateKanbanColumnOrder', () => getTeamDataService().updateKanbanColumnOrder( validatedTeamName.value!, validatedColumnId.value, ids ) ); } const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed']; async function handleUpdateTaskStatus( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, status: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } if (typeof status !== 'string' || !VALID_TASK_STATUSES.includes(status as TeamTaskStatus)) { return { success: false, error: `status must be one of: ${VALID_TASK_STATUSES.join(', ')}` }; } return wrapTeamHandler('updateTaskStatus', () => getTeamDataService().updateTaskStatus( validatedTeamName.value!, validatedTaskId.value!, status as TeamTaskStatus ) ); } async function handleSoftDeleteTask( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } return wrapTeamHandler('softDeleteTask', () => getTeamDataService().softDeleteTask(validatedTeamName.value!, validatedTaskId.value!) ); } async function handleRestoreTask( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } return wrapTeamHandler('restoreTask', () => getTeamDataService().restoreTask(validatedTeamName.value!, validatedTaskId.value!) ); } async function handleGetDeletedTasks( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } return wrapTeamHandler('getDeletedTasks', () => getTeamDataService().getDeletedTasks(validatedTeamName.value!) ); } const VALID_CLARIFICATION_VALUES = ['lead', 'user'] as const; async function handleSetTaskClarification( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, value: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } if ( value !== null && (typeof value !== 'string' || !VALID_CLARIFICATION_VALUES.includes(value as 'lead' | 'user')) ) { return { success: false, error: `value must be "lead", "user", or null`, }; } return wrapTeamHandler('setTaskClarification', () => getTeamDataService().setTaskNeedsClarification( validatedTeamName.value!, validatedTaskId.value!, value as 'lead' | 'user' | null ) ); } async function handleUpdateTaskOwner( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, owner: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } let nextOwner: string | null = null; if (owner !== null) { const validatedOwner = validateMemberName(owner); if (!validatedOwner.valid) { return { success: false, error: validatedOwner.error ?? 'Invalid owner' }; } nextOwner = validatedOwner.value!; } return wrapTeamHandler('updateTaskOwner', () => getTeamDataService().updateTaskOwner( validatedTeamName.value!, validatedTaskId.value!, nextOwner ) ); } async function handleProcessSend( _event: IpcMainInvokeEvent, teamName: unknown, message: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } if (typeof message !== 'string' || message.trim().length === 0) { return { success: false, error: 'message must be a non-empty string' }; } return wrapTeamHandler('processSend', () => getTeamProvisioningService().sendMessageToTeam(validatedTeamName.value!, message) ); } async function handleProcessAlive( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } return wrapTeamHandler('processAlive', async () => getTeamProvisioningService().isTeamAlive(validatedTeamName.value!) ); } async function handleCreateConfig( _event: IpcMainInvokeEvent, request: unknown ): Promise> { if (!request || typeof request !== 'object') { return { success: false, error: 'Invalid create config request' }; } const payload = request as Partial; if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) { return { success: false, error: 'teamName is required' }; } const teamName = payload.teamName.trim(); if (!isProvisioningTeamName(teamName)) { return { success: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' }; } if (!Array.isArray(payload.members)) { return { success: false, error: 'members must be an array' }; } if (payload.displayName !== undefined && typeof payload.displayName !== 'string') { return { success: false, error: 'displayName must be a string' }; } if (payload.description !== undefined && typeof payload.description !== 'string') { return { success: false, error: 'description must be a string' }; } if (payload.color !== undefined && typeof payload.color !== 'string') { return { success: false, error: 'color must be a string' }; } if (payload.cwd !== undefined) { if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string if provided' }; } if (!path.isAbsolute(payload.cwd.trim())) { return { success: false, error: 'cwd must be an absolute path' }; } } const seenNames = new Set(); const members: TeamCreateConfigRequest['members'] = []; for (const member of payload.members) { if (!member || typeof member !== 'object') { return { success: false, error: 'member must be object' }; } const nameValidation = validateTeammateName((member as { name?: unknown }).name); if (!nameValidation.valid) { return { success: false, error: nameValidation.error ?? 'Invalid member name' }; } const memberName = nameValidation.value!; if (seenNames.has(memberName)) { return { success: false, error: 'member names must be unique' }; } seenNames.add(memberName); const role = (member as { role?: unknown }).role; if (role !== undefined && typeof role !== 'string') { return { success: false, error: 'member role must be string' }; } const workflow = (member as { workflow?: unknown }).workflow; if (workflow !== undefined && typeof workflow !== 'string') { return { success: false, error: 'member workflow must be string' }; } const providerValidation = parseOptionalMemberProviderId( (member as { providerId?: unknown }).providerId ); if (!providerValidation.valid) { return { success: false, error: providerValidation.error }; } const model = (member as { model?: unknown }).model; if (model !== undefined && typeof model !== 'string') { return { success: false, error: 'member model must be string' }; } const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined, workflow: typeof workflow === 'string' ? workflow.trim() : undefined, providerId: providerValidation.value, model: typeof model === 'string' ? model.trim() || undefined : undefined, effort: effortValidation.value, }); } return wrapTeamHandler('createConfig', () => getTeamDataService().createTeamConfig({ teamName, displayName: payload.displayName?.trim() || undefined, description: payload.description?.trim() || undefined, color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined, members, cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined, }) ); } function getTeamMemberLogsFinder(): TeamMemberLogsFinder { if (!teamMemberLogsFinder) { throw new Error('Team member logs finder is not initialized'); } return teamMemberLogsFinder; } async function handleGetMemberLogs( _event: IpcMainInvokeEvent, teamName: unknown, memberName: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) { return { success: false, error: vTeam.error ?? 'Invalid teamName' }; } const vMember = validateMemberName(memberName); if (!vMember.valid) { return { success: false, error: vMember.error ?? 'Invalid memberName' }; } return wrapTeamHandler('getMemberLogs', () => getTeamMemberLogsFinder().findMemberLogs(vTeam.value!, vMember.value!) ); } async function handleGetLogsForTask( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, options?: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; } ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) { return { success: false, error: vTeam.error ?? 'Invalid teamName' }; } const vTask = validateTaskId(taskId); if (!vTask.valid) { return { success: false, error: vTask.error ?? 'Invalid taskId' }; } const opts = options && typeof options === 'object' ? { owner: typeof options.owner === 'string' ? options.owner : undefined, status: typeof options.status === 'string' ? options.status : undefined, since: typeof options.since === 'string' ? options.since : undefined, intervals: Array.isArray(options.intervals) ? (options.intervals as unknown[]).filter( (i): i is { startedAt: string; completedAt?: string } => Boolean(i) && typeof i === 'object' && typeof (i as Record).startedAt === 'string' && ((i as Record).completedAt === undefined || typeof (i as Record).completedAt === 'string') ) : undefined, } : undefined; return wrapTeamHandler('getLogsForTask', () => getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts) ); } function getMemberStatsComputer(): MemberStatsComputer { if (!memberStatsComputer) { throw new Error('Member stats computer is not initialized'); } return memberStatsComputer; } async function handleGetMemberStats( _event: IpcMainInvokeEvent, teamName: unknown, memberName: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) { return { success: false, error: vTeam.error ?? 'Invalid teamName' }; } const vMember = validateMemberName(memberName); if (!vMember.valid) { return { success: false, error: vMember.error ?? 'Invalid memberName' }; } return wrapTeamHandler('getMemberStats', () => getMemberStatsComputer().getStats(vTeam.value!, vMember.value!) ); } async function handleAliveList(_event: IpcMainInvokeEvent): Promise> { return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams()); } async function handleLeadActivity( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('leadActivity', async () => getTeamProvisioningService().getLeadActivityState(validated.value!) ); } async function handleLeadContext( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('leadContext', async () => getTeamProvisioningService().getLeadContextUsage(validated.value!) ); } 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 ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('stop', async () => { addMainBreadcrumb('team', 'stop', { teamName: validated.value! }); getTeamProvisioningService().stopTeam(validated.value!); }); } async function handleStartTask( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } return wrapTeamHandler('startTask', () => getTeamDataService().startTask(validatedTeamName.value!, validatedTaskId.value!) ); } async function handleStartTaskByUser( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown ): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; } const validatedTaskId = validateTaskId(taskId); if (!validatedTaskId.valid) { return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } return wrapTeamHandler('startTaskByUser', () => getTeamDataService().startTaskByUser(validatedTeamName.value!, validatedTaskId.value!) ); } async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise> { setCurrentMainOp('team:getAllTasks'); const startedAt = Date.now(); try { return await wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks()); } finally { const ms = Date.now() - startedAt; if (ms >= 1500) { logger.warn(`[teams:getAllTasks] slow ms=${ms}`); } setCurrentMainOp(null); } } async function handleAddMember( _event: IpcMainInvokeEvent, teamName: unknown, payload: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; if (!payload || typeof payload !== 'object') { return { success: false, error: 'Invalid payload' }; } const { name, role, workflow, providerId, model } = payload as { name?: unknown; role?: unknown; workflow?: unknown; providerId?: unknown; model?: unknown; effort?: 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' }; } const providerValidation = parseOptionalMemberProviderId(providerId); if (!providerValidation.valid) { return { success: false, error: providerValidation.error }; } if (model !== undefined && typeof model !== 'string') { return { success: false, error: 'model must be a string' }; } const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } return wrapTeamHandler('addMember', async () => { const tn = vTeam.value!; const memberName = vName.value!; await getTeamDataService().addMember(tn, { name: memberName, role: role, workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined, providerId: providerValidation.value, model: typeof model === 'string' ? model.trim() || undefined : undefined, effort: effortValidation.value, }); // If team is alive, notify the lead to spawn the new teammate const provisioning = getTeamProvisioningService(); if (provisioning.isTeamAlive(tn)) { const teamDataService = getTeamDataService(); let leadName = 'team-lead'; let displayName = tn; try { const [resolvedLeadName, resolvedDisplayName] = await Promise.all([ teamDataService.getLeadMemberName(tn), teamDataService.getTeamDisplayName(tn), ]); leadName = resolvedLeadName || 'team-lead'; displayName = resolvedDisplayName || tn; } catch { // Best-effort: fall back to default lead and team names } const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, { name: memberName, ...(typeof role === 'string' ? { role } : {}), ...(typeof workflow === 'string' ? { workflow } : {}), ...(providerValidation.value ? { providerId: providerValidation.value } : {}), ...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}), ...(effortValidation.value ? { effort: effortValidation.value } : {}), }); try { await provisioning.sendMessageToTeam(tn, spawnMessage); } catch { // Best-effort: lead process may not be responsive logger.warn(`Failed to notify lead about new member "${memberName}" in ${tn}`); } } }); } async function handleReplaceMembers( _event: IpcMainInvokeEvent, teamName: unknown, request: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; if (!request || typeof request !== 'object') { return { success: false, error: 'request must be an object' }; } const payload = request as { members?: unknown }; if (!Array.isArray(payload.members)) { return { success: false, error: 'members must be an array' }; } const seenNames = new Set(); const members: { name: string; role?: string; workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; effort?: 'low' | 'medium' | 'high'; }[] = []; for (const item of payload.members) { if (!item || typeof item !== 'object') { return { success: false, error: 'member must be object' }; } const m = item as { name?: unknown; role?: unknown; workflow?: unknown; providerId?: unknown; model?: unknown; effort?: unknown; }; const vName = validateTeammateName(m.name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; const name = vName.value!; if (seenNames.has(name)) return { success: false, error: 'member names must be unique' }; seenNames.add(name); if (m.role !== undefined && typeof m.role !== 'string') { return { success: false, error: 'member role must be string' }; } if (m.workflow !== undefined && typeof m.workflow !== 'string') { return { success: false, error: 'member workflow must be string' }; } const providerValidation = parseOptionalMemberProviderId( (m as { providerId?: unknown }).providerId ); if (!providerValidation.valid) { return { success: false, error: providerValidation.error }; } if (m.model !== undefined && typeof m.model !== 'string') { return { success: false, error: 'member model must be string' }; } const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } members.push({ name, role: typeof m.role === 'string' ? m.role.trim() : undefined, workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined, providerId: providerValidation.value, model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined, effort: effortValidation.value, }); } return wrapTeamHandler('replaceMembers', async () => { const tn = vTeam.value!; const teamDataService = getTeamDataService(); const previousMembers = (await teamDataService.getTeamData(tn)).members; const diff = buildReplaceMembersDiff(previousMembers, members); await teamDataService.replaceMembers(tn, { members }); const provisioning = getTeamProvisioningService(); if (!provisioning.isTeamAlive(tn)) { return; } let leadName = 'team-lead'; let displayName = tn; try { const [resolvedLeadName, resolvedDisplayName] = await Promise.all([ teamDataService.getLeadMemberName(tn), teamDataService.getTeamDisplayName(tn), ]); leadName = resolvedLeadName || 'team-lead'; displayName = resolvedDisplayName || tn; } catch { // Best-effort: fall back to default lead and team names } for (const addedMember of diff.added) { const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember); try { await provisioning.sendMessageToTeam(tn, spawnMessage); } catch { logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`); } } const summaryMessage = buildReplaceMembersSummaryMessage(diff); if (!summaryMessage) { return; } try { await provisioning.sendMessageToTeam(tn, summaryMessage); } catch { logger.warn(`Failed to notify lead about member updates in ${tn}`); } }); } async function handleRemoveMember( _event: IpcMainInvokeEvent, teamName: unknown, memberName: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vMember = validateMemberName(memberName); if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' }; return wrapTeamHandler('removeMember', async () => { const tn = vTeam.value!; const name = vMember.value!; await getTeamDataService().removeMember(tn, name); // Notify the lead about removed member const provisioning = getTeamProvisioningService(); if (provisioning.isTeamAlive(tn)) { const message = `Teammate "${name}" has been removed from the team. ` + `They will no longer participate in team activities. Please reassign their tasks if needed.`; try { await provisioning.sendMessageToTeam(tn, message); } catch { logger.warn(`Failed to notify lead about removal of "${name}" in ${tn}`); } } }); } async function handleUpdateTaskFields( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, fields: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; const tid = vTask.value!; if (!fields || typeof fields !== 'object') { return { success: false, error: 'fields must be an object' }; } const { subject, description } = fields as { subject?: unknown; description?: unknown }; if (subject !== undefined) { if (typeof subject !== 'string') return { success: false, error: 'subject must be a string' }; if (subject.trim().length === 0) return { success: false, error: 'subject cannot be empty' }; if (subject.length > 500) return { success: false, error: 'subject must be 500 characters or less' }; } if (description !== undefined && typeof description !== 'string') { return { success: false, error: 'description must be a string' }; } const validFields: { subject?: string; description?: string } = {}; if (typeof subject === 'string') validFields.subject = subject.trim(); if (typeof description === 'string') validFields.description = description; if (Object.keys(validFields).length === 0) { return { success: false, error: 'At least one field must be provided' }; } return wrapTeamHandler('updateTaskFields', async () => { const tn = vTeam.value!; await getTeamDataService().updateTaskFields(tn, tid, validFields); // Notify the lead about updated task fields const provisioning = getTeamProvisioningService(); if (provisioning.isTeamAlive(tn)) { const changedParts: string[] = []; if (validFields.subject) changedParts.push('title'); if (validFields.description !== undefined) changedParts.push('description'); const message = `Task #${tid} has been updated by the user (changed: ${changedParts.join(', ')}). ` + `New title: "${validFields.subject ?? '(unchanged)'}".`; try { await provisioning.sendMessageToTeam(tn, message); } catch { logger.warn(`Failed to notify lead about task fields update for #${tid} in ${tn}`); } } }); } async function handleUpdateMemberRole( _event: IpcMainInvokeEvent, teamName: unknown, memberName: unknown, role: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vMember = validateMemberName(memberName); if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' }; const normalizedRole = role === undefined || role === null ? undefined : typeof role === 'string' ? role.trim() || undefined : undefined; return wrapTeamHandler('updateMemberRole', async () => { const tn = vTeam.value!; const name = vMember.value!; const { oldRole, changed } = await getTeamDataService().updateMemberRole( tn, name, normalizedRole ); if (changed) { const provisioning = getTeamProvisioningService(); if (provisioning.isTeamAlive(tn)) { const oldDesc = oldRole ? `"${oldRole}"` : 'none'; const newDesc = normalizedRole ? `"${normalizedRole}"` : 'none'; const message = `Teammate "${name}" role changed from ${oldDesc} to ${newDesc}. This will take effect on next launch.`; try { await provisioning.sendMessageToTeam(tn, message); } catch { logger.warn(`Failed to notify lead about role change for "${name}" in ${tn}`); } } } }); } async function handleKillProcess( _event: IpcMainInvokeEvent, teamName: unknown, pid: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { return { success: false, error: 'pid must be a positive integer' }; } return wrapTeamHandler('killProcess', async () => { const tn = vTeam.value!; const pidNum = pid; // Read process label before killing (for notification message) let processLabel = `PID ${pidNum}`; try { const data = await getTeamDataService().getTeamData(tn); const proc = data.processes?.find((p) => p.pid === pidNum); if (proc) { processLabel = proc.label + (proc.port != null ? ` (:${proc.port})` : ''); } } catch { // best-effort label lookup } await getTeamDataService().killProcess(tn, pidNum); // Notify the team lead about the killed process const provisioning = getTeamProvisioningService(); if (provisioning.isTeamAlive(tn)) { const message = `Process "${processLabel}" (PID ${pidNum}) has been stopped by the user from the UI. ` + `You may need to restart it if it was still needed.`; try { await provisioning.sendMessageToTeam(tn, message); } catch { logger.warn(`Failed to notify lead about killed process ${pidNum} in ${tn}`); } } }); } async function handleShowMessageNotification( _event: IpcMainInvokeEvent, data: unknown ): Promise> { if (!data || typeof data !== 'object') { return { success: false, error: 'Invalid notification data' }; } const d = data as TeamMessageNotificationData; if (!d.teamDisplayName || !d.from || !d.body) { return { success: false, error: 'Missing required fields (teamDisplayName, from, body)' }; } if (!d.teamName) { return { success: false, error: 'Missing required field: teamName (needed for deep-link navigation)', }; } // Route through NotificationManager for unified storage + native toast. // dedupeKey is required from renderer — built from stable identifiers (taskId, teamName, etc.) const dedupeKey = d.dedupeKey ?? `msg:${d.teamName}:${d.from}:${d.summary ?? d.body.slice(0, 50)}`; void NotificationManager.getInstance() .addTeamNotification({ teamEventType: d.teamEventType ?? 'task_clarification', teamName: d.teamName, teamDisplayName: d.teamDisplayName, from: d.from, to: d.to, summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`, body: d.body, dedupeKey, suppressToast: d.suppressToast, }) .catch(() => undefined); return { success: true, data: undefined }; } /** * Show a native OS notification for a team event. * @deprecated Use NotificationManager.addTeamNotification() instead for unified storage + toast. * Kept for backward compatibility with any remaining callers. */ export function showTeamNativeNotification(opts: { title: string; subtitle?: string; body: string; }): void { const config = ConfigManager.getInstance().getConfig(); if (!config.notifications.enabled) { logger.debug('[native-notification] skipped: notifications disabled'); return; } if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) { logger.debug('[native-notification] skipped: snoozed'); return; } if ( typeof Notification === 'undefined' || typeof Notification.isSupported !== 'function' || !Notification.isSupported() ) { logger.warn('[native-notification] skipped: Notification not supported on this platform'); return; } const isMac = process.platform === 'darwin'; const truncatedBody = stripMarkdown(opts.body).slice(0, 300); const iconPath = isMac ? undefined : getAppIconPath(); const notification = new Notification({ title: opts.title, ...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}), body: !isMac && opts.subtitle ? `${opts.subtitle}\n${truncatedBody}` : truncatedBody, sound: config.notifications.soundEnabled ? 'default' : undefined, ...(iconPath ? { icon: iconPath } : {}), }); // Hold a strong reference to prevent GC from collecting the notification activeTeamNotifications.add(notification); const cleanup = (): void => { activeTeamNotifications.delete(notification); }; notification.on('click', () => { const windows = BrowserWindow.getAllWindows(); const mainWin = windows[0]; if (mainWin && !mainWin.isDestroyed()) { mainWin.show(); mainWin.focus(); } cleanup(); }); notification.on('close', cleanup); notification.on('show', () => { logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`); }); notification.on('failed', (_, error) => { logger.warn(`[native-notification] failed: ${error}`); cleanup(); }); notification.show(); } async function handleAddTaskComment( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, request: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; if (!request || typeof request !== 'object') { return { success: false, error: 'Invalid add task comment request' }; } const payload = request as Partial; const text = payload.text; if (typeof text !== 'string' || text.trim().length === 0) return { success: false, error: 'Comment text must be non-empty' }; if (text.trim().length > MAX_TEXT_LENGTH) return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` }; const validatedTaskRefs = validateTaskRefs(payload.taskRefs); if (!validatedTaskRefs.valid) { return { success: false, error: validatedTaskRefs.error }; } const rawAttachments = Array.isArray(payload.attachments) ? payload.attachments : []; if (rawAttachments.length > MAX_ATTACHMENTS) { return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` }; } return wrapTeamHandler('addTaskComment', async () => { // Save comment attachments (images). Done inside wrapTeamHandler so failures return IpcResult. let savedAttachments: TaskAttachmentMeta[] | undefined; if (rawAttachments.length > 0) { savedAttachments = []; for (const att of rawAttachments) { if (!att || typeof att !== 'object') { throw new Error('Invalid attachment data'); } const a = att as unknown as Record; if ( typeof a.id !== 'string' || typeof a.filename !== 'string' || typeof a.mimeType !== 'string' || typeof a.base64Data !== 'string' || a.base64Data.length === 0 || !ALLOWED_ATTACHMENT_TYPES.has(a.mimeType) ) { throw new Error('Invalid attachment data'); } const safeId = a.id.trim(); if (safeId.includes('/') || safeId.includes('\\') || safeId.includes('..')) { throw new Error('Invalid attachment ID'); } const meta = await taskAttachmentStore.saveAttachment( vTeam.value!, vTask.value!, safeId, a.filename, a.mimeType, a.base64Data ); savedAttachments.push(meta); } } return getTeamDataService().addTaskComment( vTeam.value!, vTask.value!, text.trim(), savedAttachments, validatedTaskRefs.value ); }); } const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const; type RelationshipType = (typeof VALID_RELATIONSHIP_TYPES)[number]; async function handleAddTaskRelationship( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, targetId: unknown, type: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; const vTarget = validateTaskId(targetId); if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' }; if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) { return { success: false, error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`, }; } return wrapTeamHandler('addTaskRelationship', () => getTeamDataService().addTaskRelationship( vTeam.value!, vTask.value!, vTarget.value!, type as RelationshipType ) ); } async function handleRemoveTaskRelationship( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, targetId: unknown, type: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; const vTarget = validateTaskId(targetId); if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' }; if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) { return { success: false, error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`, }; } return wrapTeamHandler('removeTaskRelationship', () => getTeamDataService().removeTaskRelationship( vTeam.value!, vTask.value!, vTarget.value!, type as RelationshipType ) ); } // --------------------------------------------------------------------------- // Task Attachment Handlers // --------------------------------------------------------------------------- async function handleSaveTaskAttachment( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, attachmentId: unknown, filename: unknown, mimeType: unknown, base64Data: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { return { success: false, error: 'attachmentId must be a non-empty string' }; } if (typeof filename !== 'string' || filename.trim().length === 0) { return { success: false, error: 'filename must be a non-empty string' }; } if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { return { success: false, error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`, }; } if (typeof base64Data !== 'string' || base64Data.length === 0) { return { success: false, error: 'base64Data must be a non-empty string' }; } // Sanitize IDs against path traversal const safeAttId = attachmentId.trim(); if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) { return { success: false, error: 'Invalid attachmentId' }; } return wrapTeamHandler('saveTaskAttachment', async () => { const meta = await taskAttachmentStore.saveAttachment( vTeam.value!, vTask.value!, safeAttId, filename, mimeType, base64Data ); // Write metadata into the task JSON await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta); return meta; }); } async function handleGetTaskAttachment( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, attachmentId: unknown, mimeType: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { return { success: false, error: 'attachmentId must be a non-empty string' }; } if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { return { success: false, error: 'Invalid mimeType' }; } const safeAttId = attachmentId.trim(); if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) { return { success: false, error: 'Invalid attachmentId' }; } return wrapTeamHandler('getTaskAttachment', () => taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType) ); } async function handleDeleteTaskAttachment( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, attachmentId: unknown, mimeType: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { return { success: false, error: 'attachmentId must be a non-empty string' }; } if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { return { success: false, error: 'Invalid mimeType' }; } const safeAttId = attachmentId.trim(); if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) { return { success: false, error: 'Invalid attachmentId' }; } return wrapTeamHandler('deleteTaskAttachment', async () => { await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType); // Remove metadata from task JSON await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); }); } async function handleToolApprovalRespond( _event: IpcMainInvokeEvent, teamName: unknown, runId: unknown, requestId: unknown, allow: unknown, message?: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } if (typeof runId !== 'string' || runId.trim().length === 0) { return { success: false, error: 'runId must be a non-empty string' }; } if (typeof requestId !== 'string' || requestId.trim().length === 0) { return { success: false, error: 'requestId must be a non-empty string' }; } if (typeof allow !== 'boolean') { return { success: false, error: 'allow must be a boolean' }; } return wrapTeamHandler('toolApprovalRespond', () => getTeamProvisioningService().respondToToolApproval( validated.value!, runId, requestId, allow, typeof message === 'string' ? message : undefined ) ); } async function handleToolApprovalSettings( _event: IpcMainInvokeEvent, teamName: unknown, settings: unknown ): Promise> { if (typeof teamName !== 'string' || teamName.trim().length === 0) { return { success: false, error: 'teamName must be a non-empty string' }; } if (typeof settings !== 'object' || settings === null) { return { success: false, error: 'Settings must be an object' }; } const s = settings as Record; if (typeof s.autoAllowAll !== 'boolean') { return { success: false, error: 'autoAllowAll must be a boolean' }; } 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( teamName, 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 }; } /** Max file size for tool approval diff preview (2MB). */ const TOOL_APPROVAL_MAX_FILE_SIZE = 2 * 1024 * 1024; async function handleToolApprovalReadFile( _event: IpcMainInvokeEvent, filePath: unknown ): Promise> { if (typeof filePath !== 'string' || filePath.trim().length === 0) { return { success: false, error: 'filePath must be a non-empty string' }; } if (!path.isAbsolute(filePath)) { return { success: false, error: 'filePath must be an absolute path' }; } try { let stats: fs.Stats; try { stats = await fs.promises.stat(filePath); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return { success: true, data: { content: '', exists: false, truncated: false, isBinary: false }, }; } throw err; } if (!stats.isFile()) { return { success: true, data: { content: '', exists: true, truncated: false, isBinary: false, error: 'Not a file' }, }; } const truncated = stats.size > TOOL_APPROVAL_MAX_FILE_SIZE; const readSize = truncated ? TOOL_APPROVAL_MAX_FILE_SIZE : stats.size; // Read file (potentially truncated) const fd = await fs.promises.open(filePath, 'r'); try { const buffer = Buffer.alloc(readSize); await fd.read(buffer, 0, readSize, 0); // Binary detection: check first 8KB for null bytes const checkSize = Math.min(readSize, 8192); for (let i = 0; i < checkSize; i++) { if (buffer[i] === 0) { return { success: true, data: { content: '', exists: true, truncated: false, isBinary: true }, }; } } return { success: true, data: { content: buffer.toString('utf-8'), exists: true, truncated, isBinary: false }, }; } finally { await fd.close(); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { success: true, data: { content: '', exists: true, truncated: false, isBinary: false, error: msg }, }; } } async function handleGetSavedRequest( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } const tn = validated.value!; const meta = await teamMetaStore.getMeta(tn); if (!meta) { return { success: true, data: null }; } const membersStore = new TeamMembersMetaStore(); const members = await membersStore.getMembers(tn); return { success: true, data: { teamName: tn, displayName: meta.displayName, description: meta.description, color: meta.color, cwd: meta.cwd, prompt: meta.prompt, providerId: meta.providerId ?? 'anthropic', model: meta.model, effort: meta.effort as TeamCreateRequest['effort'], skipPermissions: meta.skipPermissions, worktree: meta.worktree, extraCliArgs: meta.extraCliArgs, limitContext: meta.limitContext, members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow, providerId: m.providerId, model: m.model, effort: m.effort, })), }, }; } async function handleDeleteDraft( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('deleteDraft', async () => { // Only allow deleting draft teams (no config.json) const configPath = path.join(getTeamsBasePath(), validated.value!, 'config.json'); try { await fs.promises.access(configPath, fs.constants.F_OK); throw new Error('Cannot delete draft: team has config.json (use deleteTeam instead)'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } await getTeamDataService().permanentlyDeleteTeam(validated.value!); }); }