diff --git a/Cli-Proxy-API-Management-Center b/Cli-Proxy-API-Management-Center new file mode 160000 index 00000000..3f1f6a13 --- /dev/null +++ b/Cli-Proxy-API-Management-Center @@ -0,0 +1 @@ +Subproject commit 3f1f6a132336d4a985af2c2142a553f47f4a1ef6 diff --git a/README.md b/README.md index 50a1aea0..8b57c5f7 100644 --- a/README.md +++ b/README.md @@ -38,21 +38,34 @@ A new approach to task management with AI agent teams.
More features -
- **Task creation with attachments** — Simply send a message to the team lead with any attached images (planed all files). The lead will automatically create a fully described task and attach your files directly to the task for complete context. + - **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses + - **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks + - **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size. + - **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place + - **Zero-setup onboarding** — built-in Claude Code installation and authentication + - **Built-in code editor** — edit project files with Git support without leaving the app + - **Branch strategy** — choose via prompt: single branch or git worktree per agent + - **Team member stats** — global performance statistics per member + - **Attach code context** — reference files or snippets in messages, like in Cursor. You can also mention tasks using `#task-id`, or refer to another team with `@team-name` in your messages. + - **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur + - **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box + - **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost + - **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference +
## Installation diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index ece6bafc..f48f29b7 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -38,6 +38,11 @@ module.exports = { createController, createControllerContext, agentBlocks, + protocols: { + buildActionModeProtocolText: tasks.buildActionModeProtocolText, + MEMBER_DELEGATE_DESCRIPTION: tasks.MEMBER_DELEGATE_DESCRIPTION, + buildProcessProtocolText: tasks.buildProcessProtocolText, + }, tasks, kanban, review, diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index c966edd7..9a88f8fe 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -45,6 +45,9 @@ function normalizeAttachments(attachments) { filename: String(item.filename || '').trim(), mimeType: String(item.mimeType || '').trim(), size: Number(item.size || 0), + ...(typeof item.filePath === 'string' && item.filePath.trim() + ? { filePath: item.filePath.trim() } + : {}), })) .filter((item) => item.id && item.filename && item.mimeType && Number.isFinite(item.size)); diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index bf4dcade..8e5d4ed2 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -125,7 +125,7 @@ function requestReview(context, taskId, flags = {}) { `FIRST call review_start to signal you are beginning the review:\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "" }\n\n` + `When approved, use MCP tool review_approve:\n` + - `{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` + + `{ teamName: "${context.teamName}", taskId: "${task.id}", note?: "", notifyOwner: true }\n\n` + `If changes are needed, use MCP tool review_request_changes:\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }` ), diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index 78d19b90..dae273fb 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -440,6 +440,7 @@ function saveTaskAttachmentFile(paths, taskId, flags) { mimeType, size: stats.size, addedAt: nowIso(), + filePath: destPath, }; return { diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 1a211c0a..f245664e 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -349,7 +349,12 @@ function buildMemberLanguageInstruction(config) { return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`; } -function buildMemberActionModeProtocol() { +/** + * Raw action-mode protocol text parameterized by DELEGATE description. + * Shared between lead (actionModeInstructions.ts) and member (memberBriefing). + * Context-free — does NOT follow the (context, ...) convention. + */ +function buildActionModeProtocolText(delegateDescription) { return [ 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):', '- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.', @@ -359,10 +364,17 @@ function buildMemberActionModeProtocol() { '- Modes:', ' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.', ' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.', - ' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.', + ` - DELEGATE: ${delegateDescription}`, ].join('\n'); } +const MEMBER_DELEGATE_DESCRIPTION = + 'Do not implement yourself. Pass the task with full context (what you know, what is needed) to your team lead or another teammate and let them handle it.'; + +function buildMemberActionModeProtocol() { + return buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION); +} + function buildMemberTaskProtocol(teamName) { return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 0. IMPORTANT ID RULE: @@ -393,6 +405,7 @@ function buildMemberTaskProtocol(teamName) { This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review. 4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } + CRITICAL: Text comments like "approved" or "LGTM" do NOT change the kanban board. You MUST call review_approve to move a task from REVIEW to APPROVED. Without the tool call the task stays stuck in the REVIEW column. 5. If review fails and changes are needed, use MCP tool review_request_changes: { teamName: "${teamName}", taskId: "", comment: "" } 6. NEVER skip status updates. A task is NOT done until completed status is written. @@ -442,8 +455,13 @@ function buildMemberTaskProtocol(teamName) { Failure to follow this protocol means the task board will show incorrect status.`); } -function buildMemberProcessProtocol(teamName) { - return wrapAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): +/** + * Raw process-registration protocol text (no agent-block wrapping). + * Shared between member briefing and lead provisioning prompt (DRY). + * Context-free — does NOT follow the (context, ...) convention. + */ +function buildProcessProtocolText(teamName) { + return `BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): 1. Launch with & to get PID: pnpm dev & 2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port): @@ -452,7 +470,13 @@ function buildMemberProcessProtocol(teamName) { { teamName: "${teamName}" } 4. When stopping a process, use MCP tool process_stop: { teamName: "${teamName}", pid: } -If verification in step 3 fails or the process is missing from the list, re-register it.`); +5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister: + { teamName: "${teamName}", pid: } +If verification in step 3 fails or the process is missing from the list, re-register it.`; +} + +function buildMemberProcessProtocol(teamName) { + return wrapAgentBlock(buildProcessProtocolText(teamName)); } function buildMemberFormattingProtocol() { @@ -592,6 +616,9 @@ module.exports = { setTaskStatus, softDeleteTask, startTask, + buildActionModeProtocolText, + MEMBER_DELEGATE_DESCRIPTION, + buildProcessProtocolText, memberBriefing, taskBriefing, unlinkTask, diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 149dc08b..fcf7b3e1 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -98,4 +98,13 @@ declare module 'agent-teams-controller' { } export const agentBlocks: AgentBlocksApi; + + /** Context-free protocol text builders, shared across lead and member prompts. */ + export interface ProtocolsApi { + buildActionModeProtocolText(delegateDescription: string): string; + MEMBER_DELEGATE_DESCRIPTION: string; + buildProcessProtocolText(teamName: string): string; + } + + export const protocols: ProtocolsApi; } diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 3c2ee87a..bbac5d51 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -170,7 +170,7 @@ export function registerTaskTools(server: Pick) { ...(source ? { source } : {}), }; - // Preserve attachment metadata by reference only — no blob copying + // Preserve attachment metadata — filePath included when available if (Array.isArray(message.attachments) && message.attachments.length > 0) { sourceMessage.attachments = (message.attachments as Record[]) .filter( @@ -185,6 +185,7 @@ export function registerTaskTools(server: Pick) { filename: String(a.filename), mimeType: typeof a.mimeType === 'string' ? a.mimeType : '', size: typeof a.size === 'number' ? a.size : 0, + ...(typeof a.filePath === 'string' ? { filePath: a.filePath } : {}), })); } @@ -212,7 +213,11 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_get', - description: 'Get a task by id', + description: + 'Get a task by id. Response includes:\n' + + '- sourceMessage.attachments: from original user message (filePath = absolute path to file on disk, use Read tool to view)\n' + + '- attachments: files attached to the task (filePath = absolute path, use Read tool to view)\n' + + '- comments[].attachments: files on comments (filePath = absolute path, use Read tool to view)', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), @@ -314,7 +319,8 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_attach_file', - description: 'Attach a file to a task', + description: + 'Attach a file to a task. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), @@ -351,7 +357,8 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_attach_comment_file', - description: 'Attach a file to a task comment', + description: + 'Attach a file to a task comment. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 4cfc74a9..084e816c 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -117,6 +117,7 @@ function validateNotificationsSection( 'notifyOnTaskCreated', 'notifyOnAllTasksCompleted', 'notifyOnCrossTeamMessage', + 'notifyOnTeamLaunched', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -199,6 +200,12 @@ function validateNotificationsSection( } result.notifyOnCrossTeamMessage = value; break; + case 'notifyOnTeamLaunched': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnTeamLaunched = value; + break; case 'statusChangeOnlySolo': if (typeof value !== 'boolean') { return { valid: false, error: `notifications.${key} must be a boolean` }; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a2693474..2eee6d42 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -63,6 +63,7 @@ import { 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, @@ -155,6 +156,13 @@ const logger = createLogger('IPC:teams'); 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) @@ -198,6 +206,53 @@ function checkRateLimitMessages( } } +/** + * 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; @@ -443,6 +498,7 @@ async function handleGetData( 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 } }; } @@ -503,6 +559,7 @@ async function handleGetData( 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 } }; } @@ -1247,12 +1304,30 @@ async function handleSendMessage( } if (stdinSent) { - const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ - id: a.id, - filename: a.filename, - mimeType: a.mimeType, - size: a.size, - })); + // 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; @@ -1271,12 +1346,7 @@ async function handleSendMessage( result = { deliveredToInbox: false, messageId: preGeneratedMessageId }; } - // Save attachment binary data to disk (best-effort) - if (validatedAttachments?.length && result.messageId) { - void attachmentStore - .saveAttachments(tn, result.messageId, validatedAttachments) - .catch((e) => logger.warn(`Failed to save attachments: ${e}`)); - } + // Attachment files already saved above (before metadata construction) provisioning.pushLiveLeadProcessMessage(tn, { from: 'user', diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index c9056f04..6bcd9cab 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -168,7 +168,7 @@ export class ProjectScanner { const projectArrays = await this.collectFulfilledInBatches( projectDirs, this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH, - async (dir) => this.scanProject(dir.name) + async (dir) => this.scanProjectWithTimeout(dir.name) ); // Flatten and sort by most recent @@ -312,6 +312,31 @@ export class ProjectScanner { // Project Scanning (continued) // =========================================================================== + // Per-project scan timeout: prevents a single slow directory from blocking + // the entire scan batch (e.g. a project with 1000+ session files on slow I/O). + private static readonly SCAN_PROJECT_TIMEOUT_MS = 15_000; + + /** + * Scans a single project directory with a timeout guard. + * Returns empty array if the scan exceeds the timeout. + */ + private async scanProjectWithTimeout(encodedName: string): Promise { + let timer: ReturnType | null = null; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + logger.warn( + `[scanProject] timeout after ${ProjectScanner.SCAN_PROJECT_TIMEOUT_MS}ms project=${encodedName}` + ); + resolve([]); + }, ProjectScanner.SCAN_PROJECT_TIMEOUT_MS); + }); + try { + return await Promise.race([this.scanProject(encodedName), timeout]); + } finally { + if (timer) clearTimeout(timer); + } + } + /** * Scans a single project directory and returns project metadata. * If sessions have different cwd values, splits into multiple projects. @@ -319,7 +344,9 @@ export class ProjectScanner { private async scanProject(encodedName: string): Promise { try { const projectPath = path.join(this.projectsDir, encodedName); + const readdirStart = Date.now(); const entries = await this.fsProvider.readdir(projectPath); + const readdirMs = Date.now() - readdirStart; // Get session files (.jsonl at root level) const sessionFiles = entries.filter( @@ -330,6 +357,12 @@ export class ProjectScanner { return []; } + if (sessionFiles.length > 200 || readdirMs > 500) { + logger.debug( + `[scanProject] ${encodedName} readdir=${readdirMs}ms entries=${entries.length} jsonl=${sessionFiles.length}` + ); + } + // Collect file stats and cwd for each session interface SessionInfo { sessionId: string; @@ -346,6 +379,8 @@ export class ProjectScanner { const MAX_CWD_SPLIT_FILES = 80; const shouldSplitByCwd = this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES; + + const sessionStatStart = Date.now(); const sessionInfos = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH, @@ -377,6 +412,13 @@ export class ProjectScanner { return []; } + const sessionStatMs = Date.now() - sessionStatStart; + if (sessionFiles.length > 200 || sessionStatMs > 1000) { + logger.debug( + `[scanProject] ${encodedName} sessionStat=${sessionStatMs}ms files=${sessionFiles.length} infos=${sessionInfos.length}` + ); + } + // Group sessions by cwd const cwdGroups = new Map(); const firstCwd = sessionInfos.find((s) => s.cwd)?.cwd ?? undefined; diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts index 3ba119fd..240e8e80 100644 --- a/src/main/services/discovery/SubagentResolver.ts +++ b/src/main/services/discovery/SubagentResolver.ts @@ -312,7 +312,6 @@ export class SubagentResolver { * Intentionally mutates the subagent in place for consistency with other resolution methods. */ private enrichSubagentFromTask(subagent: Process, taskCall: ToolCall): void { - /* eslint-disable no-param-reassign -- Mutation is intentional; subagent is enriched in place */ subagent.parentTaskId = taskCall.id; subagent.description = taskCall.taskDescription; subagent.subagentType = taskCall.taskSubagentType; @@ -323,7 +322,6 @@ export class SubagentResolver { if (teamName && memberName) { subagent.team = { teamName, memberName, memberColor: '' }; } - /* eslint-enable no-param-reassign -- End of intentional mutation block */ } /** diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 43141da9..1fdfe40b 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -14,6 +14,7 @@ import { randomUUID } from 'crypto'; import { type ExtractedToolResult } from '../analysis/ToolResultExtractor'; import type { TriggerColor } from '@shared/constants/triggerColors'; +import type { TeamEventType } from '@shared/types/notifications'; // ============================================================================= // Types @@ -52,18 +53,7 @@ export interface DetectedError { /** Notification domain: 'error' (default/undefined) or 'team' */ category?: 'error' | 'team'; /** For team notifications: specific event sub-type */ - teamEventType?: - | 'rate_limit' - | 'lead_inbox' - | 'user_inbox' - | 'task_clarification' - | 'task_status_change' - | 'task_comment' - | 'task_created' - | 'all_tasks_completed' - | 'cross_team_message' - | 'schedule_completed' - | 'schedule_failed'; + teamEventType?: TeamEventType; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ dedupeKey?: string; /** Additional context about the error */ diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 5240a51a..765a3204 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -329,8 +329,8 @@ export class CliInstallerService { loggedIn?: boolean; authMethod?: string; }; - result.authLoggedIn = auth.loggedIn === true; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object - result.authMethod = auth.authMethod ?? null; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.authLoggedIn = auth.loggedIn === true; + result.authMethod = auth.authMethod ?? null; logger.info( `Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` + (authAttempt > 1 ? ` (attempt ${authAttempt})` : '') @@ -347,7 +347,7 @@ export class CliInstallerService { logger.warn( `Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${getErrorMessage(err)}` ); - result.authLoggedIn = false; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.authLoggedIn = false; } } } @@ -378,13 +378,13 @@ export class CliInstallerService { private async fetchLatestVersion(result: CliInstallationStatus): Promise { try { const latestRaw = await fetchText(`${GCS_BASE}/latest`); - result.latestVersion = normalizeVersion(latestRaw); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.latestVersion = normalizeVersion(latestRaw); logger.info( `Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"` ); if (result.installedVersion && result.latestVersion) { - result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion); logger.info( `Update available: ${result.updateAvailable} (${result.installedVersion} → ${result.latestVersion})` ); diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 2733948a..79999f95 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -56,6 +56,8 @@ export interface NotificationConfig { notifyOnAllTasksCompleted: boolean; /** Whether to show native OS notifications for cross-team messages */ notifyOnCrossTeamMessage: boolean; + /** Whether to show native OS notifications when a team finishes launching */ + notifyOnTeamLaunched: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -273,6 +275,7 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnTaskCreated: true, notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, + notifyOnTeamLaunched: true, statusChangeOnlySolo: false, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 58a5d2ed..c76543bd 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -18,6 +18,7 @@ import { getAppIconPath } from '@main/utils/appIcon'; import { getHomeDir } from '@main/utils/pathDecoder'; import { stripMarkdown } from '@main/utils/textFormatting'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; import { type BrowserWindow, Notification } from 'electron'; import { EventEmitter } from 'events'; @@ -429,7 +430,7 @@ export class NotificationManager extends EventEmitter { try { const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; - const truncatedBody = stripMarkdown(payload.body).slice(0, 300); + const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300); const iconPath = isMac ? undefined : getAppIconPath(); logger.debug( diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index 998139f4..675080e5 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -3,18 +3,27 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import { atomicWriteAsync } from './atomicWrite'; - import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); -const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap +const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB per file +const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB legacy JSON cap + +/** Per-attachment metadata stored in the index file. */ +interface StoredAttachmentIndex { + id: string; + filename: string; + mimeType: string; +} export class TeamAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( value.length === 0 || + value.trim().length === 0 || + value === '.' || + value === '..' || value.includes('/') || value.includes('\\') || value.includes('..') || @@ -24,37 +33,204 @@ export class TeamAttachmentStore { } } - private getDir(teamName: string): string { + private sanitizeStoredFilename(original: string): string { + const raw = String(original ?? '').trim(); + const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : ''; + const cleaned = base + .replace(/\0/g, '') + .replace(/[\r\n\t]/g, ' ') + .replace(/[\\/]/g, '_') + .trim(); + if (!cleaned) return 'attachment'; + return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned; + } + + /** Base directory for all message attachments of a team. */ + private getTeamDir(teamName: string): string { this.assertSafePathSegment('teamName', teamName); return path.join(getAppDataPath(), 'attachments', teamName); } - private getFilePath(teamName: string, messageId: string): string { + /** Directory for a specific message's attachments (new file-based format). */ + private getMessageDir(teamName: string, messageId: string): string { this.assertSafePathSegment('messageId', messageId); - return path.join(this.getDir(teamName), `${messageId}.json`); + return path.join(this.getTeamDir(teamName), messageId); } + /** Path to the metadata index file inside a message attachment directory. */ + private getIndexPath(teamName: string, messageId: string): string { + return path.join(this.getMessageDir(teamName, messageId), '_index.json'); + } + + /** Legacy JSON bundle path (old format). */ + private getLegacyFilePath(teamName: string, messageId: string): string { + this.assertSafePathSegment('messageId', messageId); + return path.join(this.getTeamDir(teamName), `${messageId}.json`); + } + + /** Stored file path for an individual attachment. */ + private getStoredFilePath( + teamName: string, + messageId: string, + attachmentId: string, + filename: string + ): string { + this.assertSafePathSegment('attachmentId', attachmentId); + const safeName = this.sanitizeStoredFilename(filename); + return path.join(this.getMessageDir(teamName, messageId), `${attachmentId}--${safeName}`); + } + + /** + * Save message attachments as individual files on disk. + * Returns a Map of attachmentId → absolute file path for each saved file. + */ async saveAttachments( teamName: string, messageId: string, attachments: AttachmentPayload[] - ): Promise { - if (attachments.length === 0) return; + ): Promise> { + const filePaths = new Map(); + if (attachments.length === 0) return filePaths; - const fileData: AttachmentFileData[] = attachments.map((a) => ({ - id: a.id, - data: a.data, - mimeType: a.mimeType, - })); + const dir = this.getMessageDir(teamName, messageId); + await fs.promises.mkdir(dir, { recursive: true }); + + const indexEntries: StoredAttachmentIndex[] = []; + + for (const att of attachments) { + const buffer = Buffer.from(att.data, 'base64'); + if (buffer.length > MAX_ATTACHMENT_SIZE) { + logger.warn( + `[${teamName}] Skipping oversized attachment ${att.id} (${(buffer.length / (1024 * 1024)).toFixed(1)} MB)` + ); + continue; + } + + const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename); + try { + await fs.promises.writeFile(storedPath, buffer); + } catch (writeError) { + logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`); + continue; + } + filePaths.set(att.id, storedPath); + + indexEntries.push({ + id: att.id, + filename: att.filename, + mimeType: att.mimeType, + }); + } + + // Write metadata index for successful files (mimeType, original filename) + if (indexEntries.length > 0) { + const indexPath = this.getIndexPath(teamName, messageId); + await fs.promises.writeFile(indexPath, JSON.stringify(indexEntries, null, 2)); + } - await atomicWriteAsync(this.getFilePath(teamName, messageId), JSON.stringify(fileData)); logger.debug( - `[${teamName}] Saved ${attachments.length} attachment(s) for message ${messageId}` + `[${teamName}] Saved ${filePaths.size} attachment file(s) for message ${messageId}` ); + return filePaths; } + /** + * Returns a Map of attachmentId → absolute file path. + * Only available for new file-based format. Legacy JSON bundles have no individual file paths. + */ + async getAttachmentFilePaths(teamName: string, messageId: string): Promise> { + const result = new Map(); + + // Try new file-based format first + const dir = this.getMessageDir(teamName, messageId); + try { + const entries = await fs.promises.readdir(dir); + for (const entry of entries) { + if (entry === '_index.json') continue; + const dashIdx = entry.indexOf('--'); + if (dashIdx > 0) { + const attachmentId = entry.slice(0, dashIdx); + result.set(attachmentId, path.join(dir, entry)); + } + } + if (result.size > 0) return result; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + // No new-format files found — not available for legacy format + return result; + } + + /** + * Read attachment data (base64) for rendering in UI. + * Supports both new file-based format and legacy JSON bundle. + */ async getAttachments(teamName: string, messageId: string): Promise { - const filePath = this.getFilePath(teamName, messageId); + // Try new file-based format first + const newResult = await this.readNewFormatAttachments(teamName, messageId); + if (newResult !== null) return newResult; + + // Fallback to legacy JSON format + return this.readLegacyAttachments(teamName, messageId); + } + + /** Read attachments from new file-based directory format. */ + private async readNewFormatAttachments( + teamName: string, + messageId: string + ): Promise { + const indexPath = this.getIndexPath(teamName, messageId); + + let indexRaw: string; + try { + indexRaw = await fs.promises.readFile(indexPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + + let index: StoredAttachmentIndex[]; + try { + const parsed = JSON.parse(indexRaw) as unknown; + if (!Array.isArray(parsed)) return null; + index = parsed as StoredAttachmentIndex[]; + } catch { + return null; + } + + const result: AttachmentFileData[] = []; + for (const entry of index) { + if (!entry || typeof entry.id !== 'string') continue; + const filename = + typeof entry.filename === 'string' && entry.filename ? entry.filename : 'attachment'; + const mimeType = + typeof entry.mimeType === 'string' && entry.mimeType + ? entry.mimeType + : 'application/octet-stream'; + const filePath = this.getStoredFilePath(teamName, messageId, entry.id, filename); + try { + const buffer = await fs.promises.readFile(filePath); + result.push({ + id: entry.id, + data: buffer.toString('base64'), + mimeType, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + } + + return result; + } + + /** Read attachments from legacy JSON bundle format. */ + private async readLegacyAttachments( + teamName: string, + messageId: string + ): Promise { + const filePath = this.getLegacyFilePath(teamName, messageId); let raw: string; try { @@ -103,5 +279,5 @@ export class TeamAttachmentStore { } // TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends. - // Best-effort removal of the attachment JSON file — useful for retry/cancel flows. + // Best-effort removal of attachment files — useful for retry/cancel flows. } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 880ac553..b8cdce82 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -230,7 +230,27 @@ export class TeamDataService { needsClarification: task.needsClarification, deletedAt: task.deletedAt, reviewState, - // Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small + // IMPORTANT: comments MUST be included here (at least lightweight metadata). + // + // Previously comments were omitted from GlobalTask payload to keep IPC small. + // This silently broke task comment notifications in the renderer: the store's + // detectTaskCommentNotifications() compares oldTask.comments vs newTask.comments + // to find new comments and fire native OS toasts. Without comments in the payload, + // both counts were always 0 → newCommentCount <= oldCommentCount → every comment + // was silently skipped → "Task comment notifications" toggle had no effect. + // + // Fix: include lightweight comment metadata (id, author, truncated text for toast + // preview, createdAt, type). Full text and attachments are still omitted — those + // are loaded on-demand by the task detail view via team:getData. + comments: Array.isArray(task.comments) + ? task.comments.map((c) => ({ + id: c.id, + author: c.author, + text: c.text.slice(0, 120), + createdAt: c.createdAt, + type: c.type, + })) + : undefined, kanbanColumn, teamName: task.teamName, teamDisplayName: info.displayName, @@ -632,7 +652,6 @@ export class TeamDataService { 2000 ); if (branch && branch !== leadBranch) { - // eslint-disable-next-line no-param-reassign -- intentional in-place enrichment member.gitBranch = branch; } } catch { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7330f878..07dfc7b3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,5 +1,5 @@ -/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; +import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { @@ -96,7 +96,7 @@ import type { } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); -const { createController } = agentTeamsControllerModule; +const { createController, protocols } = agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; @@ -111,6 +111,9 @@ const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const FS_MONITOR_POLL_MS = 2000; const TASK_WAIT_FALLBACK_MS = 15_000; +const STALL_CHECK_INTERVAL_MS = 10_000; +const STALL_WARNING_THRESHOLD_MS = 45_000; +const STALL_WARNING_REPEAT_MS = 30_000; const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; @@ -119,6 +122,13 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_list_targets', 'cross_team_get_outbox', ]); +const HANDLED_STREAM_JSON_TYPES = new Set([ + 'user', + 'assistant', + 'control_request', + 'result', + 'system', +]); const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; const PREFLIGHT_PING_ARGS = [ '-p', @@ -216,6 +226,12 @@ interface ProvisioningRun { expectedMembers: string[]; request: TeamCreateRequest; lastLogProgressAt: number; + /** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */ + lastDataReceivedAt: number; + /** Stall watchdog interval handle. Cleared in cleanupRun(). */ + stallCheckHandle: NodeJS.Timeout | null; + /** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */ + apiErrorWarningEmitted: boolean; fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; waitingTasksSince: number | null; provisioningComplete: boolean; @@ -420,7 +436,9 @@ function buildMemberSpawnPrompt( const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` : ''; - const actionModeProtocol = buildActionModeProtocol(); + const actionModeProtocol = protocols.buildActionModeProtocolText( + protocols.MEMBER_DELEGATE_DESCRIPTION + ); return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock} ${getAgentLanguageInstruction()} @@ -451,7 +469,10 @@ function buildReconnectMemberSpawnPrompt( const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` : ''; - const actionModeProtocol = indentMultiline(buildActionModeProtocol(), ' '); + const actionModeProtocol = indentMultiline( + protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION), + ' ' + ); return ` For "${member.name}": - prompt: You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock} @@ -541,6 +562,8 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `IMPORTANT: The board MCP only supports these domains: task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, ``, `Task board operations — use MCP tools directly:`, + `- Get task details: task_get { teamName: "${teamName}", taskId: "" }`, + `- List all tasks: task_list { teamName: "${teamName}" }`, `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "", subject: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, @@ -557,6 +580,17 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Link dependency: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, + `- Set clarification flag: task_set_clarification { teamName: "${teamName}", taskId: "", value: "lead"|"user"|"clear" }`, + ``, + `Review operations — use MCP tools directly (text comments do NOT change kanban state):`, + `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, + `- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "", from: "" }`, + `- Approve review: review_approve { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true }`, + `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", comment: "" }`, + `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, + ``, + `Process operations — use MCP tools directly:`, + protocols.buildProcessProtocolText(teamName), ``, `Attachment storage modes (IMPORTANT):`, `- Default is copy (safe, robust).`, @@ -662,7 +696,7 @@ Constraints: - If the request is ambiguous or still needs technical discovery, immediately create a coarse investigation/triage task for the best-fit teammate. That teammate owns the code inspection, scope refinement, and creation of any follow-up tasks needed for execution. - Only do lead-side research first if the human explicitly asked YOU for analysis/planning, or if there is genuinely no appropriate teammate to own the investigation. - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. -- When messaging "user" (the human): NEVER mention internal MCP tools, scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint} +- When messaging "user" (the human): write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint} ${teamCtlOps} @@ -831,6 +865,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { - If a task is blocked (uses blockedBy), it MUST be created as pending (for example with task_create + startImmediately: false). Do NOT mark blocked tasks in_progress. - Review guidance: - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: call review_start when beginning review, then review_approve/review_request_changes on the implementation task #X. + - CRITICAL: Text comments ("approved", "LGTM") do NOT move the task on the kanban board. You MUST call the MCP tool review_approve to move from REVIEW to APPROVED. Without the tool call, the task stays stuck in REVIEW. - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: - Use related to connect it to #X (non-blocking link). - If the review truly cannot start until #X is done, ALSO add blockedBy #X. @@ -2338,6 +2373,127 @@ export class TeamProvisioningService { this.cleanupRun(run); } + /** + * Shows a non-fatal API error warning in the Live output section. + * Unlike failProvisioningWithApiError, does NOT kill the process — lets the SDK retry. + * Deduplicates: only the first warning per run is shown. + */ + private emitApiErrorWarning(run: ProvisioningRun, text: string): void { + if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return; + if (run.progress.state === 'failed' || run.cancelRequested) return; + if (run.apiErrorWarningEmitted) return; + + run.apiErrorWarningEmitted = true; + + const snippet = this.extractApiErrorSnippet(text); + const status = /api error:\s*(\d{3})\b/i.exec(text)?.[1] ?? null; + const label = status ? `API Error ${status}` : 'API Error'; + + const warningText = snippet + ? `**${label} — SDK is retrying**\n\n\`\`\`\n${snippet}\n\`\`\`\n\nОжидаем повторной попытки...` + : `**${label} — SDK is retrying**\n\nОжидаем повторной попытки...`; + + run.provisioningOutputParts.push(warningText); + run.progress.message = `${label} — SDK retrying...`; + emitLogsProgress(run); + // Prevent double-emit: the calling stderr/stdout handler will also try throttled emitLogsProgress + // after this returns. Updating lastLogProgressAt ensures the throttle check rejects it. + run.lastLogProgressAt = Date.now(); + } + + /** + * Starts a periodic watchdog that detects when the CLI process has produced + * no stdout/stderr data for an extended period. Pushes progressive warnings + * into provisioningOutputParts so they appear in the Live output section. + */ + private startStallWatchdog(run: ProvisioningRun): void { + if (run.stallCheckHandle) return; + + let lastWarningAt = 0; + + run.stallCheckHandle = setInterval(() => { + // try/catch: Node.js does NOT catch errors in setInterval callbacks — + // without this, an exception would silently kill the watchdog. + try { + if ( + run.provisioningComplete || + run.processKilled || + run.cancelRequested || + run.authRetryInProgress + ) { + this.stopStallWatchdog(run); + return; + } + + const now = Date.now(); + const silenceMs = now - run.lastDataReceivedAt; + + if (silenceMs < STALL_WARNING_THRESHOLD_MS) return; + if (lastWarningAt > 0 && now - lastWarningAt < STALL_WARNING_REPEAT_MS) return; + + lastWarningAt = now; + const silenceSec = Math.round(silenceMs / 1000); + + run.provisioningOutputParts.push(this.buildStallWarningText(silenceSec)); + const mins = Math.floor(silenceSec / 60); + const secs = silenceSec % 60; + const elapsed = mins > 0 ? `${mins} мин ${secs > 0 ? `${secs} сек` : ''}` : `${secs} сек`; + run.progress.message = `CLI не отвечает ${elapsed} — возможен rate limit`; + emitLogsProgress(run); + } catch (err) { + logger.error( + `[${run.teamName}] Stall watchdog error: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + }, STALL_CHECK_INTERVAL_MS); + } + + private stopStallWatchdog(run: ProvisioningRun): void { + if (run.stallCheckHandle) { + clearInterval(run.stallCheckHandle); + run.stallCheckHandle = null; + } + } + + private buildStallWarningText(silenceSec: number): string { + const mins = Math.floor(silenceSec / 60); + const secs = silenceSec % 60; + const elapsed = mins > 0 ? `${mins} мин ${secs > 0 ? `${secs} сек` : ''}` : `${secs} сек`; + + if (silenceSec < 60) { + return ( + `---\n\n` + + `**Ожидание ответа CLI** (тишина ${elapsed})\n\n` + + `Процесс запущен, но пока не выдаёт данных. ` + + `Это может быть вызвано задержкой API (rate limit / model cooldown) — ` + + `SDK выполняет повторные попытки автоматически.\n\n` + + `Ожидаем...` + ); + } + + if (silenceSec < 120) { + return ( + `---\n\n` + + `**Ожидание ответа CLI** (тишина ${elapsed})\n\n` + + `Процесс по-прежнему не отвечает. Вероятна задержка из-за rate limiting ` + + `(ошибка 429 / model cooldown). SDK автоматически повторяет запрос — ` + + `обычно это проходит в течение 1-3 минут.\n\n` + + `Можно отменить и попробовать позже, если ожидание затянется.` + ); + } + + return ( + `---\n\n` + + `**Длительное ожидание CLI** (тишина ${elapsed})\n\n` + + `Процесс молчит уже более ${mins} минут. Вероятные причины:\n` + + `- Rate limiting / model cooldown (429) — SDK повторяет автоматически\n` + + `- Перегрузка API сервера\n\n` + + `Рекомендуем отменить и попробовать через несколько минут.` + ); + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -2391,6 +2547,7 @@ export class TeamProvisioningService { run.timeoutHandle = null; } this.stopFilesystemMonitor(run); + this.stopStallWatchdog(run); if (run.child) { run.child.stdout?.removeAllListeners('data'); run.child.stderr?.removeAllListeners('data'); @@ -2409,6 +2566,7 @@ export class TeamProvisioningService { run.stderrLogLineBuf = ''; run.claudeLogsUpdatedAt = undefined; run.authFailureRetried = true; + run.apiErrorWarningEmitted = false; updateProgress(run, 'spawning', 'Auth failed — retrying after short delay'); run.onProgress(run.progress); @@ -2467,6 +2625,9 @@ export class TeamProvisioningService { // Reattach stderr handler this.attachStderrHandler(run); + run.lastDataReceivedAt = Date.now(); + this.startStallWatchdog(run); + // Restart filesystem monitor for createTeam (launch skips it) if (!run.isLaunch) { this.startFilesystemMonitor(run, run.request); @@ -2518,6 +2679,8 @@ export class TeamProvisioningService { let stdoutLineBuf = ''; child.stdout.on('data', (chunk: Buffer) => { + // Reset stall watchdog FIRST — any data (even partial JSON) means the CLI is alive. + run.lastDataReceivedAt = Date.now(); const text = chunk.toString('utf8'); this.appendCliLogs(run, 'stdout', text); run.stdoutBuffer += text; @@ -2553,7 +2716,9 @@ export class TeamProvisioningService { // Not valid JSON — check for auth failure in raw text output this.handleAuthFailureInOutput(run, trimmed, 'stdout'); if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) { - this.failProvisioningWithApiError(run, trimmed); + // Show warning but do NOT kill — the SDK may be retrying internally (e.g. 429 model_cooldown). + // If all retries fail, result.subtype="error" will catch it and kill then. + this.emitApiErrorWarning(run, trimmed); } } } @@ -2572,6 +2737,8 @@ export class TeamProvisioningService { if (!child?.stderr) return; child.stderr.on('data', (chunk: Buffer) => { + // Reset stall watchdog FIRST — any data (even partial JSON) means the CLI is alive. + run.lastDataReceivedAt = Date.now(); const text = chunk.toString('utf8'); this.appendCliLogs(run, 'stderr', text); run.stderrBuffer += text; @@ -2582,7 +2749,9 @@ export class TeamProvisioningService { // Detect auth failure early instead of waiting for 5-minute timeout this.handleAuthFailureInOutput(run, text, 'stderr'); if (this.hasApiError(text) && !this.isAuthFailureWarning(text, 'stderr')) { - this.failProvisioningWithApiError(run, text); + // Show warning but do NOT kill — the SDK may be retrying internally (e.g. 429 model_cooldown). + // If all retries fail, result.subtype="error" will catch it and kill then. + this.emitApiErrorWarning(run, text); } const currentTs = Date.now(); @@ -2659,6 +2828,9 @@ export class TeamProvisioningService { expectedMembers: request.members.map((member) => member.name), request, lastLogProgressAt: 0, + lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) + stallCheckHandle: null, + apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, isLaunch: false, @@ -2772,6 +2944,11 @@ export class TeamProvisioningService { this.attachStdoutHandler(run); this.attachStderrHandler(run); + // Reset AFTER spawn — not at run init — because async operations (buildProvisioningEnv, + // writeConfigFile) between init and spawn can take seconds, causing false stall warnings. + run.lastDataReceivedAt = Date.now(); + this.startStallWatchdog(run); + // Filesystem-based progress monitor: actively polls team files instead // of relying on stdout (which only arrives at the end in text mode). // When config + members + tasks are all present, kill the process early @@ -3023,6 +3200,9 @@ export class TeamProvisioningService { expectedMembers, request: syntheticRequest, lastLogProgressAt: 0, + lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) + stallCheckHandle: null, + apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, isLaunch: true, @@ -3176,6 +3356,11 @@ export class TeamProvisioningService { this.attachStdoutHandler(run); this.attachStderrHandler(run); + // Reset AFTER spawn — not at run init — because async operations between init + // and spawn can take seconds, causing false stall warnings. + run.lastDataReceivedAt = Date.now(); + this.startStallWatchdog(run); + // For launch, skip the filesystem monitor — files (config, inboxes, tasks) // already exist from the previous run and would trigger immediate false // completion on the first poll. Rely on stream-json result.success instead. @@ -4845,6 +5030,23 @@ export class TeamProvisioningService { } } } + + // Catch-all: detect API errors in unrecognised message types. + // Guards against future protocol additions that carry error payloads + // (e.g. type: "error") which would otherwise be silently dropped. + if (typeof msg.type === 'string' && !HANDLED_STREAM_JSON_TYPES.has(msg.type)) { + const raw = JSON.stringify(msg); + logger.warn( + `[${run.teamName}] Unhandled stream-json type "${msg.type}": ${raw.slice(0, 300)}` + ); + if ( + !run.provisioningComplete && + this.hasApiError(raw) && + !this.isAuthFailureWarning(raw, 'stdout') + ) { + this.emitApiErrorWarning(run, raw); + } + } } /** @@ -5313,7 +5515,10 @@ export class TeamProvisioningService { if ( preCompleteText && this.hasApiError(preCompleteText) && - !this.isAuthFailureWarning(preCompleteText, 'pre-complete') + !this.isAuthFailureWarning(preCompleteText, 'pre-complete') && + // Skip if we already showed a warning for this error — the SDK had a chance to retry + // and the CLI reported success. Killing now would be a false positive. + !run.apiErrorWarningEmitted ) { this.failProvisioningWithApiError(run, preCompleteText); return; @@ -5332,6 +5537,7 @@ export class TeamProvisioningService { run.timeoutHandle = null; } this.stopFilesystemMonitor(run); + this.stopStallWatchdog(run); if (run.isLaunch) { await this.updateConfigPostLaunch( @@ -5382,6 +5588,9 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); + // Fire "Team Launched" notification + void this.fireTeamLaunchedNotification(run); + // Pick up any direct messages that arrived before/while reconnecting. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`) @@ -5475,12 +5684,52 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); + // Fire "Team Launched" notification + void this.fireTeamLaunchedNotification(run); + // Pick up any direct messages that arrived during provisioning. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`) ); } + // --------------------------------------------------------------------------- + // Team Launched notification + // --------------------------------------------------------------------------- + + /** + * Fires a "team_launched" notification when a team transitions to ready state. + * Uses the existing addTeamNotification() pipeline. + */ + private async fireTeamLaunchedNotification(run: ProvisioningRun): Promise { + try { + const config = ConfigManager.getInstance().getConfig(); + const suppressToast = !config.notifications.notifyOnTeamLaunched; + const displayName = run.request.displayName || run.teamName; + const body = run.isLaunch + ? `Team "${displayName}" has been launched and is ready for tasks.` + : `Team "${displayName}" has been provisioned and is ready for tasks.`; + + await NotificationManager.getInstance().addTeamNotification({ + teamEventType: 'team_launched', + teamName: run.teamName, + teamDisplayName: displayName, + from: 'system', + summary: run.isLaunch ? 'Team launched' : 'Team provisioned', + body, + dedupeKey: `team_launched:${run.teamName}:${run.runId}`, + projectPath: run.request.cwd, + suppressToast, + }); + } catch (error) { + logger.warn( + `[${run.teamName}] Failed to fire team_launched notification: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + // --------------------------------------------------------------------------- // Same-team native delivery dedup (Layer 2) // --------------------------------------------------------------------------- @@ -5748,6 +5997,7 @@ export class TeamProvisioningService { clearTimeout(run.timeoutHandle); run.timeoutHandle = null; } + this.stopStallWatchdog(run); if (run.silentUserDmForwardClearHandle) { clearTimeout(run.silentUserDmForwardClearHandle); run.silentUserDmForwardClearHandle = null; @@ -5953,6 +6203,12 @@ export class TeamProvisioningService { return; } + // IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above! + // During respawn, the old process exit fires but run.stallCheckHandle already + // points to the NEW process's watchdog. Stopping it here would kill the wrong timer. + // The authRetryInProgress guard returns early, keeping the new watchdog alive. + this.stopStallWatchdog(run); + // === Process exited AFTER provisioning completed === // This means the team went offline (crash, kill, or natural exit). if (run.provisioningComplete) { @@ -7329,4 +7585,3 @@ export class TeamProvisioningService { }); } } -/* eslint-enable no-param-reassign -- Re-enable after TeamProvisioningService class */ diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 45d95a4f..7e509f2a 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -124,6 +124,7 @@ export class TeamTaskAttachmentStore { mimeType, size: buffer.length, addedAt: new Date().toISOString(), + filePath, }; logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 45b84829..d5091d05 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -253,6 +253,9 @@ export class TeamTaskReader { mimeType: String(a.mimeType).trim(), size: a.size, addedAt: a.addedAt, + ...('filePath' in a && typeof a.filePath === 'string' + ? { filePath: a.filePath } + : {}), })); return filtered.length > 0 ? filtered : undefined; })() @@ -288,6 +291,9 @@ export class TeamTaskReader { mimeType: String(a.mimeType).trim(), size: a.size, addedAt: a.addedAt, + ...(a.filePath != null && typeof a.filePath === 'string' + ? { filePath: a.filePath } + : {}), })) : undefined, reviewState: getReviewStateFromTask({ diff --git a/src/main/services/team/actionModeInstructions.ts b/src/main/services/team/actionModeInstructions.ts index ce6f6869..ae6692a1 100644 --- a/src/main/services/team/actionModeInstructions.ts +++ b/src/main/services/team/actionModeInstructions.ts @@ -1,7 +1,13 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; +import * as agentTeamsControllerModule from 'agent-teams-controller'; import type { AgentActionMode } from '@shared/types'; +const { protocols } = agentTeamsControllerModule; + +const LEAD_DELEGATE_DESCRIPTION = + 'Strict orchestration mode for leads. Delegate the work and any needed investigation to teammates, coordinate it, and do not implement or personally research it yourself unless you are truly in SOLO MODE.'; + const ACTION_MODE_BLOCKS: Record = { do: [ 'TURN ACTION MODE: DO', @@ -27,17 +33,7 @@ const ACTION_MODE_BLOCKS: Record = { }; export function buildActionModeProtocol(): string { - return [ - 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):', - '- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.', - '- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.', - '- Never silently broaden permissions beyond the selected mode.', - '- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.', - '- Modes:', - ' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.', - ' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.', - ' - DELEGATE: Strict orchestration mode for leads. Delegate the work and any needed investigation to teammates, coordinate it, and do not implement or personally research it yourself unless you are truly in SOLO MODE.', - ].join('\n'); + return protocols.buildActionModeProtocolText(LEAD_DELEGATE_DESCRIPTION); } export function buildActionModeAgentBlock(mode: AgentActionMode | undefined): string { diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index 5b717093..f861401e 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -10,6 +10,7 @@ import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSys import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; +import type { Readable } from 'stream'; const logger = createLogger('Util:metadataExtraction'); @@ -29,6 +30,16 @@ function byteLen(chunk: string): number { return Buffer.byteLength(chunk, 'utf8'); } +function createStreamCleanup(rl: readline.Interface, fileStream: Readable): () => void { + let cleaned = false; + return (): void => { + if (cleaned) return; + cleaned = true; + rl.close(); + fileStream.destroy(); + }; +} + /** * Extract CWD (current working directory) from the first entry. * Used to get the actual project path from encoded directory names. @@ -51,23 +62,28 @@ export async function extractCwd( } const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); - let bytes = 0; - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - fileStream.destroy(); - }, JSONL_HEAD_TIMEOUT_MS); - fileStream.on('data', (chunk: string) => { - bytes += byteLen(chunk); - if (bytes > JSONL_HEAD_MAX_BYTES) { - fileStream.destroy(); - } - }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); + let bytes = 0; + let timedOut = false; + + const cleanup = createStreamCleanup(rl, fileStream); + + const timer = setTimeout(() => { + timedOut = true; + cleanup(); + }, JSONL_HEAD_TIMEOUT_MS); + + fileStream.on('data', (chunk: string) => { + bytes += byteLen(chunk); + if (bytes > JSONL_HEAD_MAX_BYTES) { + cleanup(); + } + }); + try { let lines = 0; for await (const line of rl) { @@ -84,8 +100,6 @@ export async function extractCwd( } // Only conversational entries have cwd if ('cwd' in entry && entry.cwd) { - rl.close(); - fileStream.destroy(); return entry.cwd; } } @@ -95,8 +109,7 @@ export async function extractCwd( } } finally { clearTimeout(timer); - rl.close(); - fileStream.destroy(); + cleanup(); } return null; @@ -122,23 +135,28 @@ export async function extractFirstUserMessagePreview( } const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); - let bytes = 0; - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - fileStream.destroy(); - }, JSONL_HEAD_TIMEOUT_MS); - fileStream.on('data', (chunk: string) => { - bytes += byteLen(chunk); - if (bytes > JSONL_HEAD_MAX_BYTES) { - fileStream.destroy(); - } - }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); + let bytes = 0; + let timedOut = false; + + const cleanup = createStreamCleanup(rl, fileStream); + + const timer = setTimeout(() => { + timedOut = true; + cleanup(); + }, JSONL_HEAD_TIMEOUT_MS); + + fileStream.on('data', (chunk: string) => { + bytes += byteLen(chunk); + if (bytes > JSONL_HEAD_MAX_BYTES) { + cleanup(); + } + }); + let commandFallback: { text: string; timestamp: string } | null = null; let linesRead = 0; @@ -182,8 +200,7 @@ export async function extractFirstUserMessagePreview( return commandFallback; } finally { clearTimeout(timer); - rl.close(); - fileStream.destroy(); + cleanup(); } return commandFallback; diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index 63a01d75..40b5e810 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -5,28 +5,20 @@ * to convert domain-level team payloads into the unified notification format. */ +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { randomUUID } from 'crypto'; import type { DetectedError } from '../services/error/ErrorMessageBuilder'; import type { TriggerColor } from '@shared/constants/triggerColors'; +import type { TeamEventType } from '@shared/types/notifications'; + +// Re-export for callers that import TeamEventType from this module +export type { TeamEventType } from '@shared/types/notifications'; // ============================================================================= // Types // ============================================================================= -export type TeamEventType = - | 'rate_limit' - | 'lead_inbox' - | 'user_inbox' - | 'task_clarification' - | 'task_status_change' - | 'task_comment' - | 'task_created' - | 'all_tasks_completed' - | 'cross_team_message' - | 'schedule_completed' - | 'schedule_failed'; - /** * Domain payload for team notifications. * Single source of truth — both storage and native presentation are derived from this. @@ -71,6 +63,7 @@ const TEAM_NOTIFICATION_CONFIG: Record = cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, + team_launched: { triggerName: 'Team Launched', triggerColor: 'green' }, }; // ============================================================================= @@ -91,7 +84,7 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De projectId: payload.teamName, filePath: '', source: payload.teamEventType, - message: `[${payload.from}] ${payload.body.slice(0, 300)}`, + message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`, category: 'team', teamEventType: payload.teamEventType, dedupeKey: payload.dedupeKey, diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index d02fe020..b5910cbb 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -241,14 +241,13 @@ function nowMs(): number { } function bumpSkipReason(reasons: Record, reason: string): void { - // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional reasons[reason] = (reasons[reason] || 0) + 1; } function pushSlowest(list: SlowEntry[], entry: SlowEntry, maxLen: number): void { list.push(entry); list.sort((a, b) => b.ms - a.ms); - // eslint-disable-next-line no-param-reassign -- truncate in-place is intentional + if (list.length > maxLen) list.length = maxLen; } @@ -734,10 +733,8 @@ async function readTasksDirForTeam( } function mergeTaskDiag(target: GetAllTasksDiag, source: TaskReadDiag): void { - // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional target.skipped += source.skipped; for (const [reason, count] of Object.entries(source.skipReasons)) { - // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional target.skipReasons[reason] = (target.skipReasons[reason] || 0) + count; } } diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index df86fd30..96a2eeb2 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -615,20 +615,18 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { container .querySelectorAll('mark[data-search-result="current"]') .forEach((prev) => { - /* eslint-disable no-param-reassign -- Directly mutating DOM element style/attributes is necessary for search result highlighting */ prev.setAttribute('data-search-result', 'match'); prev.style.backgroundColor = 'var(--highlight-bg-inactive)'; prev.style.color = 'var(--highlight-text-inactive)'; prev.style.boxShadow = ''; - /* eslint-enable no-param-reassign -- Re-enable after DOM mutations */ }); } - /* eslint-disable no-param-reassign -- Directly mutating DOM element style/attributes is necessary for current search result highlighting */ + el.setAttribute('data-search-result', 'current'); el.style.backgroundColor = 'var(--highlight-bg)'; el.style.color = 'var(--highlight-text)'; el.style.boxShadow = '0 0 0 1px var(--highlight-ring)'; - /* eslint-enable no-param-reassign -- Re-enable after DOM mutations */ + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 6afa98fc..2ac49c67 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -518,10 +518,15 @@ const ProjectsGrid = ({ const [visibleProjects, setVisibleProjects] = useState(maxProjects); useEffect(() => { - if (repositoryGroups.length === 0 && !repositoryGroupsLoading) { + if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) { void fetchRepositoryGroups(); } - }, [repositoryGroups.length, repositoryGroupsLoading, fetchRepositoryGroups]); + }, [ + repositoryGroups.length, + repositoryGroupsLoading, + repositoryGroupsError, + fetchRepositoryGroups, + ]); useEffect(() => { if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) { diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index eb5ff447..47bd4bdd 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -50,6 +50,7 @@ export interface SafeConfig { notifyOnTaskCreated: boolean; notifyOnAllTasksCompleted: boolean; notifyOnCrossTeamMessage: boolean; + notifyOnTeamLaunched: boolean; statusChangeOnlySolo: boolean; statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; @@ -185,9 +186,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true, notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true, notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true, - notifyOnAllTasksCompleted: - displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true, + notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true, notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true, + notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true, statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ 'in_progress', diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 1ed4f7c4..8e797cb2 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -303,6 +303,7 @@ export function useSettingsHandlers({ notifyOnTaskCreated: true, notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, + notifyOnTeamLaunched: true, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index b2cede91..94341322 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -26,6 +26,7 @@ import { Mail, MessageSquare, PartyPopper, + Rocket, Send, Users, Volume2, @@ -72,6 +73,7 @@ interface NotificationsSectionProps { | 'notifyOnTaskCreated' | 'notifyOnAllTasksCompleted' | 'notifyOnCrossTeamMessage' + | 'notifyOnTeamLaunched' | 'statusChangeOnlySolo', value: boolean ) => void; @@ -334,6 +336,17 @@ export const NotificationsSection = ({ disabled={saving || !safeConfig.notifications.enabled} /> + } + > + onNotificationToggle('notifyOnTeamLaunched', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + {/* Task Status Change Notifications — nested within team card */}
diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 620108fc..dd98b1a3 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -241,11 +241,18 @@ export const DateGroupedSessions = (): React.JSX.Element => { // Loading guards in the store actions prevent duplicate IPC calls // when the centralized init chain has already started a fetch. const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading); + const repositoryGroupsError = useStore((s) => s.repositoryGroupsError); const projectsLoading = useStore((s) => s.projectsLoading); + const projectsError = useStore((s) => s.projectsError); useEffect(() => { - if (viewMode === 'grouped' && repositoryGroups.length === 0 && !repositoryGroupsLoading) { + if ( + viewMode === 'grouped' && + repositoryGroups.length === 0 && + !repositoryGroupsLoading && + !repositoryGroupsError + ) { void fetchRepositoryGroups(); - } else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading) { + } else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) { void fetchProjects(); } }, [ @@ -253,7 +260,9 @@ export const DateGroupedSessions = (): React.JSX.Element => { repositoryGroups.length, projects.length, repositoryGroupsLoading, + repositoryGroupsError, projectsLoading, + projectsError, fetchRepositoryGroups, fetchProjects, ]); diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 9cd465c8..993cfa15 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -105,10 +105,8 @@ function restoreViewport( ): void { if (viewport.mode === 'edge') { if (viewport.edge === 'newest') { - // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation container.scrollTop = order === 'newest-first' ? 0 : container.scrollHeight; } else { - // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation container.scrollTop = order === 'newest-first' ? container.scrollHeight : 0; } return; @@ -121,7 +119,7 @@ function restoreViewport( const containerRect = container.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); const currentOffset = elRect.top - containerRect.top; - // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation + container.scrollTop += currentOffset - viewport.offsetTop; } diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 2cd656ee..485567e1 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -35,6 +35,8 @@ interface CollapsibleTeamSectionProps { headerClassName?: string; /** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */ headerContentClassName?: string; + /** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */ + keepMounted?: boolean; children: React.ReactNode; } @@ -53,6 +55,7 @@ export const CollapsibleTeamSection = ({ contentClassName, headerClassName, headerContentClassName, + keepMounted, children, }: CollapsibleTeamSectionProps): React.JSX.Element => { const [open, setOpen] = useState(defaultOpen); @@ -133,10 +136,19 @@ export const CollapsibleTeamSection = ({
{action}
)}
- {isOpen && ( -
+ {keepMounted ? ( +
{children}
+ ) : ( + isOpen && ( +
+ {children} +
+ ) )} ); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2ca3c554..2dfc1606 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -775,7 +775,10 @@ export const ActivityItem = memo( replyTaskRefs={message.taskRefs} /> ) : displayText ? ( -
+
{onReply ? ( diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 62c56aef..56f04966 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -17,6 +17,7 @@ import { areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; @@ -560,6 +561,9 @@ const LeadThoughtsGroupRowComponent = ({ return null; }, [thoughts]); + // Detect if any thought in this group is an API error + const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); + const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); const isManaged = collapseMode === 'managed'; @@ -719,8 +723,8 @@ const LeadThoughtsGroupRowComponent = ({ className="group rounded-md [overflow:clip]" style={{ backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG, - border: CARD_BORDER_STYLE, - borderLeft: `3px solid ${colors.border}`, + border: hasApiError ? '1px solid rgba(248, 113, 113, 0.3)' : CARD_BORDER_STYLE, + borderLeft: `3px solid ${hasApiError ? '#f87171' : colors.border}`, }} > {/* Header */} @@ -732,6 +736,7 @@ const LeadThoughtsGroupRowComponent = ({ 'flex select-none items-center gap-2 px-3 py-1.5', canToggleBodyVisibility ? 'cursor-pointer' : '', ].join(' ')} + style={hasApiError ? { backgroundColor: 'rgba(248, 113, 113, 0.08)' } : undefined} onClick={handleBodyToggle} onKeyDown={ canToggleBodyVisibility @@ -879,10 +884,13 @@ const LeadThoughtsGroupRowComponent = ({ ) : null} {isBodyVisible && !expanded && needsTruncation ? ( -
+
+ + {label} + + ); + })} +
+ ) : null} + + {blocksIds.length > 0 ? ( +
+ + + Blocks + + {blocksIds.map((id) => { + const depTask = taskMap.get(id); + const isCompleted = depTask?.status === 'completed'; + const label = depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(id)}`; + return ( + + + + + {label} + + ); + })} +
+ ) : null} +
+ ) : null} + {/* Description */}
) : null} - {blockedByIds.length > 0 || - blocksIds.length > 0 || - relatedIds.length > 0 || - relatedByIds.length > 0 || - kanbanTaskState?.reviewer || - kanbanTaskState?.errorDescription ? ( + {/* Review info */} + {kanbanTaskState?.reviewer || kanbanTaskState?.errorDescription ? (
- {/* Dependencies */} - {blockedByIds.length > 0 ? ( -
- - - Blocked by +
+ {kanbanTaskState.reviewer ? ( + + Reviewer: {kanbanTaskState.reviewer} - {blockedByIds.map((id) => { - const depTask = taskMap.get(id); - const isCompleted = depTask?.status === 'completed'; - const label = depTask - ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` - : `#${deriveTaskDisplayId(id)}`; - return ( - - - - - {label} - - ); - })} -
- ) : null} - - {blocksIds.length > 0 ? ( -
- - - Blocks - - {blocksIds.map((id) => { - const depTask = taskMap.get(id); - const isCompleted = depTask?.status === 'completed'; - const label = depTask - ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` - : `#${deriveTaskDisplayId(id)}`; - return ( - - - - - {label} - - ); - })} -
- ) : null} - - {/* Review info */} - {kanbanTaskState?.reviewer || kanbanTaskState?.errorDescription ? ( -
- {kanbanTaskState.reviewer ? ( - - Reviewer: {kanbanTaskState.reviewer} - - ) : null} - {kanbanTaskState.errorDescription ? ( - - {kanbanTaskState.errorDescription} - - ) : null} -
- ) : null} + ) : null} + {kanbanTaskState.errorDescription ? ( + {kanbanTaskState.errorDescription} + ) : null} +
) : null} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index cb2ea94e..d47227b7 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,8 +1,14 @@ import React, { useEffect, useRef, useState } from 'react'; import { Label } from '@renderer/components/ui/label'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { Check, ChevronDown } from 'lucide-react'; +import { Check, ChevronDown, Info } from 'lucide-react'; // --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- @@ -164,7 +170,7 @@ export const TeamModelSelector: React.FC = ({ type="button" id={opt.value === value ? id : undefined} className={cn( - 'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', + 'flex items-center gap-1 rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', value === opt.value ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]' @@ -172,6 +178,20 @@ export const TeamModelSelector: React.FC = ({ onClick={() => onValueChange(opt.value)} > {opt.label} + {opt.value === '' && ( + + + e.stopPropagation()}> + + + + Default model from Claude CLI (/model). +
+ Currently Sonnet 4.6, but may change with CLI updates. +
+
+
+ )} ))}
diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index 9c0a4271..3172ccab 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -583,7 +583,6 @@ const RootDropZone = React.forwardRef< (el: HTMLDivElement | null) => { setNodeRef(el); if (typeof ref === 'function') ref(el); - // eslint-disable-next-line no-param-reassign -- combining forwarded ref with droppable ref else if (ref) ref.current = el; }, [ref, setNodeRef] diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 0c998968..1a22160f 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -65,27 +65,30 @@ const DependencyBadge = ({ }: DependencyBadgeProps): React.JSX.Element => { const depTask = taskMap.get(taskId); const isCompleted = depTask?.status === 'completed'; + const label = depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(taskId)}`; return ( - + + + + + {label} + ); }; @@ -349,7 +352,7 @@ export const KanbanTaskCard = ({ {hasBlockedBy ? (
- + Blocked by diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 9ae749bb..1bc7c59d 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -9,7 +9,6 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { Bell, CheckCheck, @@ -321,7 +320,7 @@ export const MessagesPanel = memo(function MessagesPanel({ ); const messagesContent = ( - <> +
- +
); // ---- Sidebar mode ---- @@ -497,7 +496,7 @@ export const MessagesPanel = memo(function MessagesPanel({
)} {/* Scrollable content */} -
+
= { }, /** Reserved for the human user — never assigned to team members. */ user: { - border: '#f5f5f4', - borderLight: '#78716c', - badge: 'rgba(245, 245, 244, 0.12)', - badgeLight: 'rgba(87, 83, 78, 0.18)', + border: '#a8a29e', + borderLight: '#57534e', + badge: 'rgba(168, 162, 158, 0.18)', + badgeLight: 'rgba(68, 64, 60, 0.14)', text: '#d6d3d1', - textLight: '#44403c', + textLight: '#292524', }, }; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 91ef7aaf..af1995ea 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -7,6 +7,7 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -145,8 +146,9 @@ function detectClarificationNotifications( function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void { // Delegate to main process for native OS notification (cross-platform, no permission needed) const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined; - const body = + const rawBody = latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`; + const body = stripAgentBlocks(rawBody).trim(); void api.teams ?.showMessageNotification({ @@ -295,7 +297,11 @@ function fireTaskCommentNotification( comment: { author: string; text: string; id: string }, suppressToast: boolean ): void { - const preview = comment.text.length > 100 ? comment.text.slice(0, 100) + '...' : comment.text; + // Double-check: never notify about user's own comments + if (comment.author === 'user') return; + + const stripped = stripAgentBlocks(comment.text).trim(); + const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped; void api.teams ?.showMessageNotification({ @@ -337,7 +343,7 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean): from: task.owner ?? 'system', to: 'user', summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`, - body: task.description || task.subject, + body: stripAgentBlocks(task.description || task.subject).trim(), teamEventType: 'task_created', dedupeKey: `created:${task.teamName}:${task.id}`, suppressToast, diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts index 94d2cecc..e479c843 100644 --- a/src/renderer/utils/chipUtils.ts +++ b/src/renderer/utils/chipUtils.ts @@ -317,8 +317,7 @@ export function calculateMentionPositions( // Character after name must be boundary if (end < text.length) { const after = text[end]; - // eslint-disable-next-line no-useless-escape - if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue; + if (!/[\s,.:;!?)\]}-]/.test(after)) continue; } matches.push({ item: suggestion, start: i, end, token: text.slice(i, end) }); i = end; diff --git a/src/renderer/utils/mentionLinkify.ts b/src/renderer/utils/mentionLinkify.ts index ca7466b4..24d51a38 100644 --- a/src/renderer/utils/mentionLinkify.ts +++ b/src/renderer/utils/mentionLinkify.ts @@ -25,11 +25,12 @@ export function linkifyMentionsInMarkdown( const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length); const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const pattern = new RegExp( + // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp `(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi' ); - return text.replace(pattern, (_match, prefix: string, name: string) => { + return text.replace(pattern, (_match: string, prefix: string, name: string) => { // Find the canonical name (case-insensitive lookup) const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; const color = memberColorMap.get(canonical) ?? ''; @@ -49,18 +50,19 @@ export function linkifyTeamMentionsInMarkdown( text: string, teamNames: ReadonlySet | readonly string[] ): string { - const names = Array.isArray(teamNames) ? teamNames : [...teamNames]; + const names: readonly string[] = Array.isArray(teamNames) ? teamNames : [...teamNames]; if (names.length === 0) return text; // Sort by name length descending for greedy matching const sorted = [...names].sort((a, b) => b.length - a.length); const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const pattern = new RegExp( + // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp `(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi' ); - return text.replace(pattern, (_match, prefix: string, name: string) => { + return text.replace(pattern, (_match: string, prefix: string, name: string) => { const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`; }); diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index 2a8a398c..e384deca 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -47,7 +47,8 @@ export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undef } if (sources.length === 0) return undefined; - const earliest = sources.reduce((a, b) => (a < b ? a : b)); + const [first, ...rest] = sources; + const earliest = rest.reduce((a, b) => (a < b ? a : b), first); const date = new Date(earliest); date.setTime(date.getTime() - TASK_SINCE_GRACE_MS); return date.toISOString(); diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts index d1ceef72..7426a8cd 100644 --- a/src/renderer/utils/taskReferenceUtils.ts +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -13,7 +13,7 @@ const ZERO_WIDTH_TO_BITS = new Map( function isAllowedTaskRefBoundary(char: string | undefined): boolean { if (!char) return true; - return !/[A-Za-z0-9_]/.test(char); + return !/\w/.test(char); } function buildSuggestionsByRef( diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index cbae55f1..4eac6397 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -33,14 +33,14 @@ export function filterTeamMessages( const hasTo = filter.to.size > 0; if (hasFrom && hasTo) { list = list.filter((m) => { - const fromMatch = m.from?.trim() && filter.from.has(m.from.trim()); - const toMatch = m.to?.trim() && filter.to.has(m.to.trim()); + const fromMatch = Boolean(m.from?.trim() && filter.from.has(m.from.trim())); + const toMatch = Boolean(m.to?.trim() && filter.to.has(m.to.trim())); return fromMatch && toMatch; }); } else if (hasFrom || hasTo) { list = list.filter((m) => { - if (hasFrom) return m.from?.trim() && filter.from.has(m.from.trim()); - if (hasTo) return m.to?.trim() && filter.to.has(m.to.trim()); + if (hasFrom) return Boolean(m.from?.trim() && filter.from.has(m.from.trim())); + if (hasTo) return Boolean(m.to?.trim() && filter.to.has(m.to.trim())); return true; }); } diff --git a/src/renderer/utils/urlMatchUtils.ts b/src/renderer/utils/urlMatchUtils.ts index 878164a9..2d4b24ce 100644 --- a/src/renderer/utils/urlMatchUtils.ts +++ b/src/renderer/utils/urlMatchUtils.ts @@ -4,9 +4,10 @@ export interface TextMatch { value: string; } -const URL_REGEX = /https?:\/\/[^\s]+/g; +const URL_REGEX = /https?:\/\/\S+/g; function trimUrlMatch(rawUrl: string): string { + // eslint-disable-next-line sonarjs/slow-regex -- trailing punctuation only, input bounded return rawUrl.replace(/[),.!?;:]+$/g, ''); } diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts index f4a949ce..c54decfd 100644 --- a/src/shared/constants/crossTeam.ts +++ b/src/shared/constants/crossTeam.ts @@ -38,7 +38,10 @@ function unescapeCrossTeamAttribute(value: string): string { function parseCrossTeamAttributes(raw: string): Map { const attrs = new Map(); - const matches = raw.matchAll(/([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g); + const matches = raw.matchAll( + /* eslint-disable-next-line sonarjs/slow-regex -- attr values bounded by quotes, trusted prefix input */ + /([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g + ); for (const match of matches) { const key = match[1]?.trim(); const value = match[2]; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9b5bb57f..f19ba5f7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -40,7 +40,6 @@ import type { AddMemberRequest, AddTaskCommentRequest, AttachmentFileData, - CommentAttachmentPayload, CreateTaskRequest, CrossTeamMessage, CrossTeamSendRequest, diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index 577bd32a..cc971734 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -100,10 +100,12 @@ export interface McpServerDiagnostic { // ── Install request (renderer → main, minimal trusted data) ──────────────── +export type McpInstallScope = 'local' | 'user' | 'project'; + export interface McpInstallRequest { registryId: string; // server ID from registry (NOT full catalog item) serverName: string; // user-chosen name for `claude mcp add` - scope: 'local' | 'user' | 'project'; + scope: McpInstallScope; projectPath?: string; // required for 'project' scope envValues: Record; headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag) @@ -113,7 +115,7 @@ export interface McpInstallRequest { export interface McpCustomInstallRequest { serverName: string; - scope: 'local' | 'user' | 'project'; + scope: McpInstallScope; projectPath?: string; installSpec: McpInstallSpec; // user provides directly envValues: Record; diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index f5cc5e57..66bdb2a0 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -15,6 +15,24 @@ import type { TriggerColor } from '@shared/constants/triggerColors'; // Detected Error Types // ============================================================================= +/** + * Team notification event sub-types. + * Single source of truth — used by DetectedError, TeamNotificationPayload, and TEAM_NOTIFICATION_CONFIG. + */ +export type TeamEventType = + | 'rate_limit' + | 'lead_inbox' + | 'user_inbox' + | 'task_clarification' + | 'task_status_change' + | 'task_comment' + | 'task_created' + | 'all_tasks_completed' + | 'cross_team_message' + | 'schedule_completed' + | 'schedule_failed' + | 'team_launched'; + /** * Detected error from session JSONL files. * Used for notification display and deep linking to error locations. @@ -53,18 +71,7 @@ export interface DetectedError { /** Notification domain: 'error' (default/undefined) or 'team' */ category?: 'error' | 'team'; /** For team notifications: specific event sub-type */ - teamEventType?: - | 'rate_limit' - | 'lead_inbox' - | 'user_inbox' - | 'task_clarification' - | 'task_status_change' - | 'task_comment' - | 'task_created' - | 'all_tasks_completed' - | 'cross_team_message' - | 'schedule_completed' - | 'schedule_failed'; + teamEventType?: TeamEventType; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ dedupeKey?: string; /** Additional context */ @@ -280,6 +287,8 @@ export interface AppConfig { notifyOnAllTasksCompleted: boolean; /** Whether to show native OS notifications for cross-team messages */ notifyOnCrossTeamMessage: boolean; + /** Whether to show native OS notifications when a team finishes launching */ + notifyOnTeamLaunched: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c13d9d24..1d016592 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -154,8 +154,14 @@ export interface SourceMessageSnapshot { timestamp: string; /** Message source type (e.g. "user_sent", "inbox"). */ source?: string; - /** Attachment metadata references (IDs only, no blobs). */ - attachments?: { id: string; filename: string; mimeType: string; size: number }[]; + /** Attachment metadata references (IDs only, no blobs). filePath present when file is stored on disk. */ + attachments?: { + id: string; + filename: string; + mimeType: string; + size: number; + filePath?: string; + }[]; } // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. @@ -229,6 +235,8 @@ export interface TaskAttachmentMeta { size: number; /** ISO timestamp when the attachment was added. */ addedAt: string; + /** Absolute path to the file on disk. Null/absent for metadata-only references. */ + filePath?: string | null; } /** Payload for uploading an attachment with base64 data (renderer → main). */ @@ -256,6 +264,8 @@ export interface AttachmentMeta { filename: string; mimeType: AttachmentMediaType; size: number; + /** Absolute path to the file on disk. Absent for metadata-only references. */ + filePath?: string; } export interface AttachmentPayload extends AttachmentMeta { diff --git a/src/shared/utils/apiErrorDetector.ts b/src/shared/utils/apiErrorDetector.ts new file mode 100644 index 00000000..c6683b2b --- /dev/null +++ b/src/shared/utils/apiErrorDetector.ts @@ -0,0 +1,13 @@ +/** + * Detects API error messages from Claude CLI output. + * Pattern: "API Error: " at the beginning of the text. + */ + +const API_ERROR_RE = /^API Error:\s*\d{3}/; + +/** + * Returns true if the message text starts with "API Error: ". + */ +export function isApiErrorMessage(text: string): boolean { + return API_ERROR_RE.test(text); +} diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index e3f701e0..5f86ca7b 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -12,7 +12,11 @@ export function normalizeRepoUrl(url: string): string { return url .toLowerCase() .replace(/\.git$/, '') - .replace(/\/+$/, ''); + .replace( + /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, URL length bounded */ + /\/+$/, + '' + ); } /** @@ -112,7 +116,11 @@ export function parseGitHubOwnerRepo(url: string): { owner: string; repo: string const parts = parsed.pathname .replace(/^\//, '') .replace(/\.git$/, '') - .replace(/\/+$/, '') + .replace( + /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, pathname bounded */ + /\/+$/, + '' + ) .split('/'); if (parts.length < 2 || !parts[0] || !parts[1]) return null; return { owner: parts[0], repo: parts[1] }; diff --git a/src/shared/utils/pricing.ts b/src/shared/utils/pricing.ts index 7a175df1..bf504472 100644 --- a/src/shared/utils/pricing.ts +++ b/src/shared/utils/pricing.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line no-restricted-imports -- resources/ is outside src/, no alias available import pricingData from '../../../resources/pricing.json'; export interface LiteLLMPricing { diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index 779ce3a5..b2b490b2 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -34,7 +34,9 @@ export function parseToolSummary(summary: string | undefined): ToolSummaryData | if (!match) return null; const byName: Record = {}; for (const part of match[2].split(', ')) { - const m = /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part); + const m = + // eslint-disable-next-line security/detect-unsafe-regex -- part from split, bounded by summary + /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part); if (m) { byName[m[2]] = parseInt(m[1], 10); } else { diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index c5b8a8d9..7c173299 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -86,7 +86,16 @@ declare module 'agent-teams-controller' { crossTeam: ControllerCrossTeamApi; } + /** Context-free protocol text builders, shared across lead and member prompts. */ + export interface ProtocolsApi { + buildActionModeProtocolText(delegateDescription: string): string; + MEMBER_DELEGATE_DESCRIPTION: string; + buildProcessProtocolText(teamName: string): string; + } + export function createController(options: ControllerContextOptions): AgentTeamsController; export const agentBlocks: AgentBlocksApi; + + export const protocols: ProtocolsApi; } diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index b3e343c7..b4d35a66 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -114,6 +114,7 @@ describe('configValidation', () => { 'notifyOnUserInbox', 'notifyOnClarifications', 'notifyOnStatusChange', + 'notifyOnTeamLaunched', 'statusChangeOnlySolo', ] as const)('accepts boolean %s toggle', (key) => { const resultOn = validateConfigUpdatePayload('notifications', { [key]: true }); @@ -134,6 +135,7 @@ describe('configValidation', () => { 'notifyOnUserInbox', 'notifyOnClarifications', 'notifyOnStatusChange', + 'notifyOnTeamLaunched', 'statusChangeOnlySolo', ] as const)('rejects non-boolean %s', (key) => { const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' }); diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 0f8b68ac..d9e68057 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -103,6 +103,12 @@ vi.mock('agent-teams-controller', () => ({ hoisted.sendInboxMessage(teamName, message), }, }), + protocols: { + buildActionModeProtocolText: (delegate: string) => + `ACTION MODE PROTOCOL (mock, delegate: ${delegate})`, + buildProcessProtocolText: (teamName: string) => + `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`, + }, })); import type { TeamChangeEvent } from '@shared/types/team'; diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 355c4e89..38b65822 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -121,6 +121,12 @@ vi.mock('agent-teams-controller', () => ({ hoisted.sendInboxMessage(teamName, message), }, }), + protocols: { + buildActionModeProtocolText: (delegate: string) => + `ACTION MODE PROTOCOL (mock, delegate: ${delegate})`, + buildProcessProtocolText: (teamName: string) => + `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`, + }, })); import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; diff --git a/test/main/utils/teamNotificationBuilder.test.ts b/test/main/utils/teamNotificationBuilder.test.ts index 7765f604..0dee4cee 100644 --- a/test/main/utils/teamNotificationBuilder.test.ts +++ b/test/main/utils/teamNotificationBuilder.test.ts @@ -98,6 +98,7 @@ describe('buildDetectedErrorFromTeam', () => { cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, + team_launched: { triggerName: 'Team Launched', triggerColor: 'green' }, }; for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) { diff --git a/test/renderer/utils/chipUtils.test.ts b/test/renderer/utils/chipUtils.test.ts index 6e55be71..dab34a3c 100644 --- a/test/renderer/utils/chipUtils.test.ts +++ b/test/renderer/utils/chipUtils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { chipToken } from '@renderer/types/inlineChip'; import { + calculateMentionPositions, createChipFromSelection, findChipBoundary, isInsideChip, @@ -251,3 +252,54 @@ describe('removeChipTokenFromText', () => { expect(removeChipTokenFromText(text, chip)).toBe('A\nB'); }); }); + +describe('calculateMentionPositions boundary regex', () => { + function makeTextarea(): HTMLTextAreaElement { + const ta = document.createElement('textarea'); + ta.style.cssText = 'font:16px monospace;width:400px;height:100px'; + document.body.appendChild(ta); + return ta; + } + + function makeMemberSuggestion(name: string) { + return { id: name, name, type: 'member' as const }; + } + + it('matches @mention when char after is boundary: space, comma, dot', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alice ', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice,', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice.', suggestions)).toHaveLength(1); + document.body.removeChild(ta); + }); + + it('matches @mention when char after is boundary: colon, semicolon, bang, question', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alice:', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice;', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice!', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice?', suggestions)).toHaveLength(1); + document.body.removeChild(ta); + }); + + it('matches @mention when char after is boundary: ), ], }, -', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alice)', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice]', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice}', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice-', suggestions)).toHaveLength(1); + document.body.removeChild(ta); + }); + + it('does NOT match @mention when char after is word char (letter, digit)', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alicex', suggestions)).toHaveLength(0); + expect(calculateMentionPositions(ta, '@Alice1', suggestions)).toHaveLength(0); + expect(calculateMentionPositions(ta, '@Alice_', suggestions)).toHaveLength(0); + document.body.removeChild(ta); + }); +}); diff --git a/test/renderer/utils/mentionLinkify.test.ts b/test/renderer/utils/mentionLinkify.test.ts new file mode 100644 index 00000000..8ded692a --- /dev/null +++ b/test/renderer/utils/mentionLinkify.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + linkifyAllMentionsInMarkdown, + linkifyMentionsInMarkdown, + linkifyTeamMentionsInMarkdown, +} from '@renderer/utils/mentionLinkify'; + +describe('mentionLinkify', () => { + it('linkifies @member after space', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyMentionsInMarkdown('hello @Alice world', m); + expect(r).toContain('mention://'); + expect(r).toContain('Alice'); + expect(r).not.toBe('hello @Alice world'); + }); + + it('does NOT linkify @ in email', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyMentionsInMarkdown('email@test.com', m); + expect(r).toBe('email@test.com'); + }); + + it('linkifies @team after (', () => { + const r = linkifyTeamMentionsInMarkdown('(@TeamAlpha)', ['TeamAlpha']); + expect(r).toContain('team://'); + expect(r).toContain('TeamAlpha'); + }); + + it('linkifyAll applies both member and team', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyAllMentionsInMarkdown('Hi @Alice from @TeamX', m, ['TeamX']); + expect(r).toContain('mention://'); + expect(r).toContain('team://'); + }); + + it('linkifies @ after start of string', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyMentionsInMarkdown('@Alice hello', m); + expect(r).toContain('mention://'); + }); + + it('linkifies @ after [ { (', () => { + const m = new Map([['Bob', 'red']]); + expect(linkifyMentionsInMarkdown('[@Bob]', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('{@Bob}', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('(@Bob)', m)).toContain('mention://'); + }); + + it('does NOT linkify @ when followed by word char', () => { + const m = new Map([['Alice', 'blue']]); + expect(linkifyMentionsInMarkdown('@AliceX', m)).toBe('@AliceX'); + expect(linkifyMentionsInMarkdown('@Alice123', m)).toBe('@Alice123'); + }); + + it('linkifies when followed by boundary: space, comma, dot, ), ], }', () => { + const m = new Map([['Alice', 'blue']]); + expect(linkifyMentionsInMarkdown('@Alice ', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice,', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice.', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice)', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice]', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice}', m)).toContain('mention://'); + }); +}); diff --git a/test/renderer/utils/taskReferenceUtils.test.ts b/test/renderer/utils/taskReferenceUtils.test.ts new file mode 100644 index 00000000..bfdcabba --- /dev/null +++ b/test/renderer/utils/taskReferenceUtils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTaskLinkHref, + linkifyTaskIdsInMarkdown, + parseTaskLinkHref, +} from '@renderer/utils/taskReferenceUtils'; + +import type { TaskRef } from '@shared/types'; + +describe('taskReferenceUtils', () => { + describe('TASK_REF_REGEX and isAllowedTaskRefBoundary', () => { + it('linkifies #ref when preceded by boundary (space, start)', () => { + const taskRef: TaskRef = { + taskId: 't1', + displayId: 'task-1', + teamName: 'my-team', + }; + const r = linkifyTaskIdsInMarkdown('see #task-1 done', [taskRef]); + expect(r).toContain('task://'); + expect(r).toContain('[#task-1]'); + }); + + it('does NOT linkify #ref when preceded by word char', () => { + const taskRef: TaskRef = { + taskId: 't1', + displayId: 'task1', + teamName: 'my-team', + }; + const r = linkifyTaskIdsInMarkdown('x#task1', [taskRef]); + expect(r).toBe('x#task1'); + }); + + it('linkifies #ref with hyphen in id', () => { + const r = linkifyTaskIdsInMarkdown(' #abc-123 '); + expect(r).toContain('task://'); + }); + }); + + describe('buildTaskLinkHref and parseTaskLinkHref', () => { + it('roundtrips task ref', () => { + const ref: TaskRef = { + taskId: 'tid-1', + displayId: 'T-1', + teamName: 'team-a', + }; + const href = buildTaskLinkHref(ref); + expect(href).toContain('task://'); + expect(href).toContain('team='); + expect(href).toContain('display='); + + const parsed = parseTaskLinkHref(href); + expect(parsed).toEqual({ + taskId: 'tid-1', + teamName: 'team-a', + displayId: 'T-1', + }); + }); + + it('parseTaskLinkHref returns null for non-task URL', () => { + expect(parseTaskLinkHref('https://example.com')).toBeNull(); + expect(parseTaskLinkHref('mention://x')).toBeNull(); + }); + + it('parseTaskLinkHref handles task:// without query', () => { + const r = parseTaskLinkHref('task://tid-1'); + expect(r).toEqual({ taskId: 'tid-1' }); + }); + }); +}); diff --git a/test/renderer/utils/urlMatchUtils.test.ts b/test/renderer/utils/urlMatchUtils.test.ts new file mode 100644 index 00000000..a3ea0e20 --- /dev/null +++ b/test/renderer/utils/urlMatchUtils.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { + findUrlBoundary, + findUrlMatches, + removeUrlMatchFromText, +} from '@renderer/utils/urlMatchUtils'; + +describe('urlMatchUtils', () => { + describe('findUrlMatches URL_REGEX', () => { + it('matches http and https URLs', () => { + const m1 = findUrlMatches('see https://example.com'); + expect(m1).toHaveLength(1); + expect(m1[0].value).toBe('https://example.com'); + + const m2 = findUrlMatches('see http://foo.bar/path'); + expect(m2).toHaveLength(1); + expect(m2[0].value).toBe('http://foo.bar/path'); + }); + + it('matches URL with query and hash', () => { + const m = findUrlMatches('https://x.com?a=1#anchor'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://x.com?a=1#anchor'); + }); + + it('returns empty for text without URLs', () => { + expect(findUrlMatches('no url here')).toEqual([]); + expect(findUrlMatches('')).toEqual([]); + }); + + it('matches multiple URLs', () => { + const m = findUrlMatches('a https://a.com b https://b.com c'); + expect(m).toHaveLength(2); + expect(m[0].value).toBe('https://a.com'); + expect(m[1].value).toBe('https://b.com'); + }); + }); + + describe('trimUrlMatch trailing punctuation regex', () => { + it('strips trailing ), . ! ? ; :', () => { + const m = findUrlMatches('check (https://example.com).'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://example.com'); + }); + + it('strips trailing comma', () => { + const m = findUrlMatches('see https://x.com, and more'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://x.com'); + }); + + it('strips multiple trailing punctuation', () => { + const m = findUrlMatches('(https://x.com)...'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://x.com'); + }); + }); + + describe('findUrlBoundary', () => { + it('returns match when cursor inside URL', () => { + const text = 'go to https://example.com now'; + const m = findUrlBoundary(text, 12); + expect(m).not.toBeNull(); + expect(m!.value).toBe('https://example.com'); + }); + + it('returns null when cursor outside URL', () => { + const text = 'go to https://example.com now'; + expect(findUrlBoundary(text, 0)).toBeNull(); + expect(findUrlBoundary(text, 100)).toBeNull(); + }); + }); + + describe('removeUrlMatchFromText', () => { + it('removes URL from text', () => { + const text = 'see https://x.com here'; + const matches = findUrlMatches(text); + expect(matches).toHaveLength(1); + const result = removeUrlMatchFromText(text, matches[0]); + expect(result).toBe('see here'); + }); + }); +}); diff --git a/test/shared/constants/crossTeam.test.ts b/test/shared/constants/crossTeam.test.ts index 6d8351f7..04731701 100644 --- a/test/shared/constants/crossTeam.test.ts +++ b/test/shared/constants/crossTeam.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { parseCrossTeamPrefix, stripCrossTeamPrefix } from '@shared/constants/crossTeam'; +import { + formatCrossTeamPrefix, + parseCrossTeamPrefix, + stripCrossTeamPrefix, +} from '@shared/constants/crossTeam'; describe('crossTeam protocol helpers', () => { it('parses canonical cross-team prefix metadata', () => { @@ -19,8 +23,25 @@ describe('crossTeam protocol helpers', () => { it('strips canonical prefix from UI text', () => { expect( stripCrossTeamPrefix('\nHello') - ).toBe( - 'Hello' + ).toBe('Hello'); + }); + + it('parseCrossTeamAttributes regex: parses attr="value" pairs', () => { + const text = formatCrossTeamPrefix('team.user', 0, { + conversationId: 'c1', + replyToConversationId: 'c0', + }); + const parsed = parseCrossTeamPrefix(text + '\nbody'); + expect(parsed).not.toBeNull(); + expect(parsed!.from).toBe('team.user'); + expect(parsed!.conversationId).toBe('c1'); + expect(parsed!.replyToConversationId).toBe('c0'); + }); + + it('handles depth attribute', () => { + const parsed = parseCrossTeamPrefix( + '\nHi' ); + expect(parsed?.chainDepth).toBe(2); }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index ff28a938..ce40a00d 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -10,6 +10,7 @@ import { inferCapabilities, normalizeCategory, normalizeRepoUrl, + parseGitHubOwnerRepo, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -168,3 +169,35 @@ describe('sanitizeMcpServerName', () => { expect(sanitizeMcpServerName('Context7')).toBe('context7'); }); }); + +describe('parseGitHubOwnerRepo', () => { + it('extracts owner/repo from https URL', () => { + expect(parseGitHubOwnerRepo('https://github.com/owner/repo')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + it('strips .git suffix', () => { + expect(parseGitHubOwnerRepo('https://github.com/owner/repo.git')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + it('strips trailing slashes', () => { + expect(parseGitHubOwnerRepo('https://github.com/owner/repo/')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + it('returns null for non-GitHub URLs', () => { + expect(parseGitHubOwnerRepo('https://gitlab.com/owner/repo')).toBeNull(); + expect(parseGitHubOwnerRepo('https://example.com')).toBeNull(); + }); + + it('returns null for invalid URL', () => { + expect(parseGitHubOwnerRepo('not-a-url')).toBeNull(); + }); +}); diff --git a/test/shared/utils/toolSummary.test.ts b/test/shared/utils/toolSummary.test.ts new file mode 100644 index 00000000..2a243d52 --- /dev/null +++ b/test/shared/utils/toolSummary.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildToolSummary, + formatToolSummary, + formatToolSummaryFromMap, + parseToolSummary, +} from '@shared/utils/toolSummary'; + +describe('toolSummary', () => { + describe('parseToolSummary simple format regex', () => { + it('parses "3 tools"', () => { + const r = parseToolSummary('3 tools'); + expect(r).toEqual({ total: 3, byName: {} }); + }); + + it('parses "1 tool"', () => { + const r = parseToolSummary('1 tool'); + expect(r).toEqual({ total: 1, byName: {} }); + }); + + it('returns null for invalid format', () => { + expect(parseToolSummary('invalid')).toBeNull(); + expect(parseToolSummary('')).toBeNull(); + expect(parseToolSummary(undefined)).toBeNull(); + }); + }); + + describe('parseToolSummary legacy format regex', () => { + it('parses "3 tools (Read, 2 Edit)"', () => { + const r = parseToolSummary('3 tools (Read, 2 Edit)'); + expect(r).not.toBeNull(); + expect(r!.total).toBe(3); + expect(r!.byName).toEqual({ Read: 1, Edit: 2 }); + }); + + it('parses "1 tool (Bash)"', () => { + const r = parseToolSummary('1 tool (Bash)'); + expect(r).not.toBeNull(); + expect(r!.total).toBe(1); + expect(r!.byName).toEqual({ Bash: 1 }); + }); + + it('parses tool names with spaces "2 tools (2 Web Search)"', () => { + const r = parseToolSummary('2 tools (2 Web Search)'); + expect(r).not.toBeNull(); + expect(r!.total).toBe(2); + expect(r!.byName['Web Search']).toBe(2); + }); + }); + + describe('buildToolSummary', () => { + it('returns "1 tool" for single tool_use', () => { + const content = [{ type: 'tool_use', name: 'Read', input: {} }]; + expect(buildToolSummary(content)).toBe('1 tool'); + }); + + it('returns "3 tools" for multiple', () => { + const content = [ + { type: 'tool_use', name: 'Read', input: {} }, + { type: 'tool_use', name: 'Edit', input: {} }, + { type: 'tool_use', name: 'Read', input: {} }, + ]; + expect(buildToolSummary(content)).toBe('3 tools'); + }); + + it('returns undefined for empty', () => { + expect(buildToolSummary([])).toBeUndefined(); + }); + }); + + describe('formatToolSummary', () => { + it('formats singular and plural', () => { + expect(formatToolSummary({ total: 1, byName: {} })).toBe('1 tool'); + expect(formatToolSummary({ total: 2, byName: {} })).toBe('2 tools'); + }); + }); + + describe('formatToolSummaryFromMap', () => { + it('returns undefined for empty map', () => { + expect(formatToolSummaryFromMap(new Map())).toBeUndefined(); + }); + + it('formats from map', () => { + const m = new Map([['Read', 2], ['Edit', 1]]); + expect(formatToolSummaryFromMap(m)).toBe('3 tools'); + }); + }); +});