diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index 9e701255..85d617d1 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -4,6 +4,7 @@ const kanban = require('./internal/kanban.js'); const review = require('./internal/review.js'); const messages = require('./internal/messages.js'); const processes = require('./internal/processes.js'); +const maintenance = require('./internal/maintenance.js'); function bindModule(context, moduleApi) { return Object.fromEntries( @@ -24,6 +25,7 @@ function createController(options) { review: bindModule(context, review), messages: bindModule(context, messages), processes: bindModule(context, processes), + maintenance: bindModule(context, maintenance), }; } @@ -35,4 +37,5 @@ module.exports = { review, messages, processes, + maintenance, }; diff --git a/agent-teams-controller/src/internal/kanbanStore.js b/agent-teams-controller/src/internal/kanbanStore.js index e6f70608..f19c4ee9 100644 --- a/agent-teams-controller/src/internal/kanbanStore.js +++ b/agent-teams-controller/src/internal/kanbanStore.js @@ -112,8 +112,49 @@ function updateColumnOrder(paths, teamName, columnId, orderedTaskIds) { return state; } +function garbageCollect(paths, teamName, validTaskIds) { + const state = readKanbanState(paths, teamName); + let staleKanbanEntriesRemoved = 0; + let staleColumnOrderRefsRemoved = 0; + + for (const taskId of Object.keys(state.tasks)) { + if (!validTaskIds.has(taskId)) { + delete state.tasks[taskId]; + staleKanbanEntriesRemoved += 1; + } + } + + if (state.columnOrder && typeof state.columnOrder === 'object') { + const cleaned = {}; + for (const [columnId, orderedTaskIds] of Object.entries(state.columnOrder)) { + if (!Array.isArray(orderedTaskIds)) { + continue; + } + + const validIds = orderedTaskIds.filter((taskId) => validTaskIds.has(String(taskId))); + staleColumnOrderRefsRemoved += orderedTaskIds.length - validIds.length; + if (validIds.length > 0) { + cleaned[columnId] = validIds; + } + } + + state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined; + } + + if (staleKanbanEntriesRemoved > 0 || staleColumnOrderRefsRemoved > 0) { + writeKanbanState(paths, teamName, state); + } + + return { + state, + staleKanbanEntriesRemoved, + staleColumnOrderRefsRemoved, + }; +} + module.exports = { clearKanban, + garbageCollect, readKanbanState, setKanbanColumn, updateColumnOrder, diff --git a/agent-teams-controller/src/internal/maintenance.js b/agent-teams-controller/src/internal/maintenance.js new file mode 100644 index 00000000..e650fc6e --- /dev/null +++ b/agent-teams-controller/src/internal/maintenance.js @@ -0,0 +1,170 @@ +const fs = require('fs'); +const path = require('path'); + +const kanbanStore = require('./kanbanStore.js'); +const taskStore = require('./taskStore.js'); + +function listInboxNames(paths) { + const inboxDir = path.join(paths.teamDir, 'inboxes'); + let entries = []; + try { + entries = fs.readdirSync(inboxDir); + } catch (error) { + if (error && error.code === 'ENOENT') { + return []; + } + throw error; + } + + return entries + .filter((name) => name.endsWith('.json') && !name.startsWith('.')) + .map((name) => name.replace(/\.json$/, '')); +} + +function readInboxMessages(paths) { + const messages = []; + + for (const member of listInboxNames(paths)) { + const inboxPath = path.join(paths.teamDir, 'inboxes', `${member}.json`); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + } catch { + continue; + } + + if (!Array.isArray(parsed)) { + continue; + } + + for (const item of parsed) { + if (!item || typeof item !== 'object') { + continue; + } + + if ( + typeof item.from !== 'string' || + typeof item.text !== 'string' || + typeof item.timestamp !== 'string' + ) { + continue; + } + + messages.push({ + from: item.from, + to: typeof item.to === 'string' ? item.to : member, + text: item.text, + timestamp: item.timestamp, + summary: typeof item.summary === 'string' ? item.summary : undefined, + messageId: typeof item.messageId === 'string' ? item.messageId : undefined, + source: typeof item.source === 'string' ? item.source : undefined, + }); + } + } + + messages.sort((a, b) => { + const bt = Date.parse(b.timestamp); + const at = Date.parse(a.timestamp); + if (Number.isNaN(bt) || Number.isNaN(at)) { + return 0; + } + return bt - at; + }); + + return messages; +} + +function isAutomatedCommentNotification(message) { + const summary = typeof message.summary === 'string' ? message.summary : ''; + if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false; + + const text = typeof message.text === 'string' ? message.text : ''; + if (!text) return false; + + if (text.includes('Reply to this comment using:')) return true; + if (text.startsWith('Comment on task #')) return true; + if (text.startsWith('New comment from user on your task #')) return true; + return false; +} + +function syncLinkedComments(paths, tasks, messages) { + const taskIdPattern = /#([A-Za-z0-9-]+)/g; + const tasksById = new Map(); + const processedTexts = new Set(); + let linkedCommentsCreated = 0; + + for (const task of tasks) { + tasksById.set(task.id, task); + if (task.displayId) { + tasksById.set(task.displayId, task); + } + } + + for (const message of messages) { + if (!message.messageId || !message.summary || message.from === 'user') continue; + if (message.source === 'lead_session' || message.source === 'lead_process') continue; + if (message.source === 'system_notification') continue; + if (isAutomatedCommentNotification(message)) continue; + + const textKey = `${message.from}\0${message.text}`; + if (processedTexts.has(textKey)) continue; + processedTexts.add(textKey); + + const taskRefs = new Set(); + for (const match of message.summary.matchAll(taskIdPattern)) { + taskRefs.add(match[1]); + } + + for (const taskRef of taskRefs) { + const task = tasksById.get(taskRef); + if (!task) continue; + + const commentId = `msg-${message.messageId}`; + const existingComments = Array.isArray(task.comments) ? task.comments : []; + if (existingComments.some((comment) => comment.id === commentId)) { + continue; + } + + try { + taskStore.addTaskComment(paths, task.id, message.text, { + id: commentId, + author: message.from, + createdAt: message.timestamp, + }); + linkedCommentsCreated += 1; + } catch { + // Best-effort: reconcile should not fail on individual comment sync writes. + } + } + } + + return linkedCommentsCreated; +} + +function reconcileArtifacts(context, options = {}) { + const garbageCollectKanban = options.garbageCollectKanban !== false; + const shouldSyncLinkedComments = options.syncLinkedComments !== false; + const tasks = taskStore.listTasks(context.paths); + + const gcResult = garbageCollectKanban + ? kanbanStore.garbageCollect( + context.paths, + context.teamName, + new Set(tasks.map((task) => task.id)) + ) + : { staleKanbanEntriesRemoved: 0, staleColumnOrderRefsRemoved: 0 }; + + const linkedCommentsCreated = shouldSyncLinkedComments + ? syncLinkedComments(context.paths, tasks, readInboxMessages(context.paths)) + : 0; + + return { + staleKanbanEntriesRemoved: gcResult.staleKanbanEntriesRemoved, + staleColumnOrderRefsRemoved: gcResult.staleColumnOrderRefsRemoved, + linkedCommentsCreated, + }; +} + +module.exports = { + reconcileArtifacts, +}; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index aa4a461d..0d89b767 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -111,4 +111,86 @@ describe('agent-teams-controller API', () => { expect(rows[0].stoppedAt).toBeTruthy(); expect(rows[1].id).toBe(registered.id); }); + + it('reconciles stale kanban rows and linked inbox comments idempotently', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ + subject: 'Ship migration', + owner: 'bob', + }); + + const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'); + fs.writeFileSync( + kanbanPath, + JSON.stringify( + { + teamName: 'my-team', + reviewers: [], + tasks: { + [task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null }, + staleTask: { column: 'approved', movedAt: '2026-01-01T00:00:00.000Z' }, + }, + columnOrder: { + review: [task.id, 'staleTask'], + approved: ['staleTask'], + }, + }, + null, + 2 + ) + ); + + const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes'); + fs.mkdirSync(inboxDir, { recursive: true }); + fs.writeFileSync( + path.join(inboxDir, 'bob.json'), + JSON.stringify( + [ + { + from: 'alice', + to: 'bob', + summary: `Please revisit #${task.displayId}`, + messageId: 'm-1', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + text: 'Need one more verification pass.', + }, + { + from: 'team-lead', + to: 'bob', + summary: `Comment on #${task.displayId}`, + messageId: 'm-2', + timestamp: '2026-02-23T11:00:00.000Z', + read: false, + text: + `Comment on task #${task.displayId} "Ship migration":\n\nHeads up\n\n` + + '\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n', + }, + ], + null, + 2 + ) + ); + + const first = controller.maintenance.reconcileArtifacts({ reason: 'manual' }); + expect(first.staleKanbanEntriesRemoved).toBe(1); + expect(first.staleColumnOrderRefsRemoved).toBe(2); + expect(first.linkedCommentsCreated).toBe(1); + + const reloaded = controller.tasks.getTask(task.id); + expect(reloaded.comments).toHaveLength(1); + expect(reloaded.comments[0].id).toBe('msg-m-1'); + expect(reloaded.comments[0].text).toBe('Need one more verification pass.'); + + const cleanedKanban = JSON.parse(fs.readFileSync(kanbanPath, 'utf8')); + expect(cleanedKanban.tasks.staleTask).toBeUndefined(); + expect(cleanedKanban.columnOrder.review).toEqual([task.id]); + expect(cleanedKanban.columnOrder.approved).toBeUndefined(); + + const second = controller.maintenance.reconcileArtifacts({ reason: 'manual' }); + expect(second.staleKanbanEntriesRemoved).toBe(0); + expect(second.staleColumnOrderRefsRemoved).toBe(0); + expect(second.linkedCommentsCreated).toBe(0); + }); }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 07f85bb6..8b861a0f 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -56,12 +56,17 @@ declare module 'agent-teams-controller' { listProcesses(): unknown[]; } + export interface ControllerMaintenanceApi { + reconcileArtifacts(flags?: Record): unknown; + } + export interface AgentTeamsController { tasks: ControllerTaskApi; kanban: ControllerKanbanApi; review: ControllerReviewApi; messages: ControllerMessageApi; processes: ControllerProcessApi; + maintenance: ControllerMaintenanceApi; } export function createController(options: ControllerContextOptions): AgentTeamsController; diff --git a/src/main/index.ts b/src/main/index.ts index 58d07297..a8a0376d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -431,11 +431,9 @@ function wireFileWatcherEvents(context: ServiceContext): void { // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { if (teamDataService) { - void teamDataService - .reconcileTeamArtifacts(teamName) - .catch((e: unknown) => - logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`) - ); + void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) => + logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`) + ); } // Auto-relay ONLY lead-inbox changes into the live lead process. @@ -487,11 +485,9 @@ function wireFileWatcherEvents(context: ServiceContext): void { // --- Task change events: notify lead when teammate starts a task via CLI --- if (row.type === 'task' && detail.endsWith('.json') && teamDataService) { - void teamDataService - .reconcileTeamArtifacts(teamName) - .catch((e: unknown) => - logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`) - ); + void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) => + logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`) + ); const taskId = detail.replace('.json', ''); void teamDataService diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 3d5aa7c0..dfa00b45 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -48,6 +48,7 @@ import { TEAM_STOP, TEAM_TOOL_APPROVAL_RESPOND, TEAM_UPDATE_CONFIG, + TEAM_VALIDATE_CLI_ARGS, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, TEAM_UPDATE_MEMBER_ROLE, @@ -59,6 +60,8 @@ 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 type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; +import { extractFlagsFromHelp, extractUserFlags, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; @@ -250,6 +253,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment); ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment); ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond); + ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs); logger.info('Team handlers registered'); } @@ -305,6 +309,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND); + ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS); } function getTeamDataService(): TeamDataService { @@ -641,6 +646,30 @@ async function validateProvisioningRequest( return { valid: false, error: 'cwd must be a directory' }; } + if (payload.worktree !== undefined) { + if (typeof payload.worktree !== 'string') { + return { valid: false, error: 'worktree must be a string' }; + } + const wt = payload.worktree.trim(); + if (wt.length > 128) { + return { valid: false, error: 'worktree name too long (max 128)' }; + } + if (wt && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(wt)) { + return { + valid: false, + error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]', + }; + } + } + if (payload.extraCliArgs !== undefined) { + if (typeof payload.extraCliArgs !== 'string') { + return { valid: false, error: 'extraCliArgs must be a string' }; + } + if (payload.extraCliArgs.length > 1024) { + return { valid: false, error: 'extraCliArgs too long (max 1024)' }; + } + } + return { valid: true, value: { @@ -655,6 +684,14 @@ async function validateProvisioningRequest( effort: isValidEffort(payload.effort) ? payload.effort : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, + worktree: + typeof payload.worktree === 'string' && payload.worktree.trim() + ? payload.worktree.trim() + : undefined, + extraCliArgs: + typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim() + ? payload.extraCliArgs.trim() + : undefined, }, }; } @@ -763,6 +800,12 @@ async function handleLaunchTeam( clearContext: payload.clearContext === true ? true : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, + worktree: + typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined, + extraCliArgs: + typeof payload.extraCliArgs === 'string' + ? payload.extraCliArgs.trim() || undefined + : undefined, }, (progress) => { try { @@ -776,6 +819,32 @@ async function handleLaunchTeam( ); } +async function handleValidateCliArgs( + _event: IpcMainInvokeEvent, + rawArgs: unknown +): Promise> { + if (typeof rawArgs !== 'string') { + return { success: false, error: 'rawArgs must be a string' }; + } + if (rawArgs.length > 2048) { + return { success: false, error: 'rawArgs too long (max 2048)' }; + } + return wrapTeamHandler('validateCliArgs', async () => { + const helpOutput = await getTeamProvisioningService().getCliHelpOutput(); + const knownFlags = extractFlagsFromHelp(helpOutput); + const userFlags = extractUserFlags(rawArgs); + + const invalidFlags = userFlags.filter((f) => !knownFlags.has(f)); + const protectedFlags = userFlags.filter((f) => PROTECTED_CLI_FLAGS.has(f)); + const allBad = [...new Set([...invalidFlags, ...protectedFlags])]; + + return { + valid: allBad.length === 0, + invalidFlags: allBad.length > 0 ? allBad : undefined, + }; + }); +} + async function handlePrepareProvisioning( _event: IpcMainInvokeEvent, cwd: unknown diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts index 451ec745..bdd46077 100644 --- a/src/main/services/team/TaskBoundaryParser.ts +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -31,10 +31,26 @@ interface ToolUseInfo { filePath?: string; } -/** Regex для teamctl task команд */ +/** + * Historical fallback for legacy CLI sessions only. + * New runtime sessions are expected to emit structured task updates via MCP/TaskUpdate. + */ const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/; const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']); +function pickDetectedMechanism( + current: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none', + next: 'TaskUpdate' | 'teamctl' | 'mcp' +): 'TaskUpdate' | 'teamctl' | 'mcp' | 'none' { + const priority = { + none: 0, + teamctl: 1, + TaskUpdate: 2, + mcp: 3, + } as const; + return priority[next] > priority[current] ? next : current; +} + export class TaskBoundaryParser { private cache = new Map(); private readonly cacheTtl = 60 * 1000; // 60s @@ -91,25 +107,25 @@ export class TaskBoundaryParser { allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp }); } - // Пробуем TaskUpdate + // Prefer structured task markers for modern runtime sessions. const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp); if (taskUpdateBounds.length > 0) { - detectedMechanism = 'TaskUpdate'; + detectedMechanism = pickDetectedMechanism(detectedMechanism, 'TaskUpdate'); boundaries.push(...taskUpdateBounds); continue; } const mcpBounds = this.extractMcpTaskBoundaries(content, lineNumber, timestamp); if (mcpBounds.length > 0) { - detectedMechanism = 'mcp'; + detectedMechanism = pickDetectedMechanism(detectedMechanism, 'mcp'); boundaries.push(...mcpBounds); continue; } - // Пробуем teamctl + // Legacy CLI fallback for historical JSONL rows. const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp); if (teamctlBounds.length > 0) { - detectedMechanism = 'teamctl'; + detectedMechanism = pickDetectedMechanism(detectedMechanism, 'teamctl'); boundaries.push(...teamctlBounds); } } catch { @@ -273,7 +289,7 @@ export class TaskBoundaryParser { } /** - * Найти teamctl task start/complete/set-status команды в Bash tool_use блоках. + * Historical fallback: detect legacy teamctl task commands in Bash tool_use blocks. * Regex: /task\s+(start|complete|set-status)\s+(\d+)/ */ private extractTeamctlBoundaries( diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a6edaf1e..0ccfb18e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1230,93 +1230,9 @@ export class TeamDataService { } async reconcileTeamArtifacts(teamName: string): Promise { - const tasks = await this.taskReader.getTasks(teamName); - await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id))); - - const messages = await this.inboxReader.getMessages(teamName); - if (messages.length === 0) { - return; - } - - await this.syncLinkedComments(teamName, tasks, messages); - } - - /** - * Scans inbox messages for task-related discussions and auto-creates - * linked comments on disk. Uses deterministic comment ID for dedup. - * Returns true if any new comments were synced (caller should re-read tasks). - */ - private async syncLinkedComments( - teamName: string, - tasks: TeamTask[], - messages: InboxMessage[] - ): Promise { - const TASK_ID_PATTERN = /#([A-Za-z0-9-]+)/g; - let synced = false; - - const tasksById = new Map(); - for (const t of tasks) { - tasksById.set(t.id, t); - if (t.displayId) { - tasksById.set(t.displayId, t); - } - } - - // Dedup broadcasts: same sender + same text → process only once - const processedTexts = new Set(); - - function isAutomatedCommentNotification(msg: InboxMessage): boolean { - const summary = typeof msg.summary === 'string' ? msg.summary : ''; - if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false; - const text = typeof msg.text === 'string' ? msg.text : ''; - if (!text) return false; - // These are system-generated inbox messages that already correspond to a real task comment. - // Syncing them into task.comments causes an immediate "duplicate" (lead echo) in the UI. - if (text.includes('Reply to this comment using:')) return true; - if (text.startsWith('Comment on task #')) return true; - if (text.startsWith('New comment from user on your task #')) return true; - return false; - } - - for (const msg of messages) { - if (!msg.messageId || !msg.summary || msg.from === 'user') continue; - if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; - if (msg.source === 'system_notification') continue; - if (isAutomatedCommentNotification(msg)) continue; - - const textKey = `${msg.from}\0${msg.text}`; - if (processedTexts.has(textKey)) continue; - processedTexts.add(textKey); - - const matches = msg.summary.matchAll(TASK_ID_PATTERN); - const taskIds = new Set(); - for (const match of matches) { - taskIds.add(match[1]); - } - - for (const taskId of taskIds) { - const task = tasksById.get(taskId); - if (!task) continue; - - const commentId = `msg-${msg.messageId}`; - const existing = task.comments ?? []; - if (existing.some((c) => c.id === commentId)) continue; - - try { - this.getController(teamName).tasks.addTaskComment(task.id, { - text: msg.text, - id: commentId, - from: msg.from, - createdAt: msg.timestamp, - }); - synced = true; - } catch { - // Best-effort — don't fail reconciliation on sync errors - } - } - } - - return synced; + this.getController(teamName).maintenance.reconcileArtifacts({ + reason: 'file-watch', + }); } private async extractLeadSessionTexts(config: TeamConfig): Promise { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index a74e95ab..ed3bf56d 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -307,7 +307,11 @@ export class TeamMemberLogsFinder { return paths; } - /** Быстрая проверка: содержит ли файл TaskUpdate/teamctl маркер для данного taskId */ + /** + * Fast marker probe for task-related logs. + * Prefer structured MCP/TaskUpdate markers for modern sessions; keep teamctl text matching + * only as historical fallback for old JSONL data. + */ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 46f8e074..95b2d36d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,6 +19,7 @@ import { } from '@shared/constants/agentBlocks'; import { getMemberColor } from '@shared/constants/memberColors'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; +import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -1080,6 +1081,9 @@ export class TeamProvisioningService { private readonly relayedLeadInboxFallbackKeys = new Map>(); private readonly liveLeadProcessMessages = new Map(); private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; + private helpOutputCache: string | null = null; + private helpOutputCacheTime = 0; + private static readonly HELP_CACHE_TTL_MS = 5 * 60 * 1000; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -1867,6 +1871,8 @@ export class TeamProvisioningService { ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []), ...(request.model ? ['--model', request.model] : []), ...(request.effort ? ['--effort', request.effort] : []), + ...(request.worktree ? ['--worktree', request.worktree] : []), + ...parseCliArgs(request.extraCliArgs), ]; try { child = spawnCli(claudePath, spawnArgs, { @@ -2209,6 +2215,10 @@ export class TeamProvisioningService { if (request.effort) { launchArgs.push('--effort', request.effort); } + if (request.worktree) { + launchArgs.push('--worktree', request.worktree); + } + launchArgs.push(...parseCliArgs(request.extraCliArgs)); // New sessions: CLI creates its own ID. No --resume with synthetic name — docs say // --resume is for existing sessions and may show an interactive picker if not found. @@ -5227,6 +5237,33 @@ export class TeamProvisioningService { return {}; } + /** + * Run `claude --help` and return the output. Cached for 5 minutes. + * Used by the validateCliArgs IPC handler to check user-entered flags. + */ + async getCliHelpOutput(cwd?: string): Promise { + if ( + this.helpOutputCache && + Date.now() - this.helpOutputCacheTime < TeamProvisioningService.HELP_CACHE_TTL_MS + ) { + return this.helpOutputCache; + } + const targetCwd = cwd ?? process.cwd(); + const probeResult = await this.getCachedOrProbeResult(targetCwd); + if (!probeResult?.claudePath) { + throw new Error('Claude CLI not found'); + } + const { env } = await this.buildProvisioningEnv(); + const result = await this.spawnProbe(probeResult.claudePath, ['--help'], targetCwd, env, 10_000); + const output = (result.stdout + '\n' + result.stderr).trim(); + if (!output) { + throw new Error(`claude --help returned empty output (exit code: ${result.exitCode})`); + } + this.helpOutputCache = output; + this.helpOutputCacheTime = Date.now(); + return output; + } + private async spawnProbe( claudePath: string, args: string[], diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 690be05b..18b29dc0 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -364,6 +364,9 @@ export const TEAM_TOOL_APPROVAL_EVENT = 'team:toolApprovalEvent'; /** Invoke: respond to a tool approval request (renderer → main) */ export const TEAM_TOOL_APPROVAL_RESPOND = 'team:toolApprovalRespond'; +/** Validate custom CLI args against `claude --help` output */ +export const TEAM_VALIDATE_CLI_ARGS = 'team:validateCliArgs'; + // ============================================================================= // CLI Installer API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index c67e5c02..cbb08237 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -106,6 +106,7 @@ import { TEAM_TOOL_APPROVAL_EVENT, TEAM_TOOL_APPROVAL_RESPOND, TEAM_UPDATE_CONFIG, + TEAM_VALIDATE_CLI_ARGS, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, TEAM_UPDATE_MEMBER_ROLE, @@ -221,6 +222,7 @@ import type { UpdateKanbanPatch, WslClaudeRootCandidate, } from '@shared/types'; +import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; import type { BinaryPreviewResult, CreateDirResponse, @@ -994,6 +996,9 @@ const electronAPI: ElectronAPI = { message ); }, + validateCliArgs: async (rawArgs: string) => { + return invokeIpcWithResult(TEAM_VALIDATE_CLI_ARGS, rawArgs); + }, onToolApprovalEvent: ( callback: (event: unknown, data: ToolApprovalEvent) => void ): (() => void) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index cd3552b8..ec49058e 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -897,6 +897,9 @@ export class HttpAPIClient implements ElectronAPI { respondToToolApproval: async (): Promise => { throw new Error('Tool approval not available in browser mode'); }, + validateCliArgs: async (): Promise => { + throw new Error('CLI args validation not available in browser mode'); + }, onToolApprovalEvent: (): (() => void) => { return () => {}; }, diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index 5d6ffe13..72e87b92 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -26,11 +26,7 @@ export const defaultTaskFiltersState = (): TaskFiltersState => ({ }); export function taskMatchesStatus( - task: { - status: string; - reviewState?: 'none' | 'review' | 'approved'; - kanbanColumn?: 'review' | 'approved'; - }, + task: { status: string; reviewState?: 'none' | 'review' | 'approved'; kanbanColumn?: 'review' | 'approved' }, statusIds: Set ): boolean { if (statusIds.size === 0) return false; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index f1cf76e1..698adf60 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -128,9 +128,9 @@ const NoiseRow = ({ ); // --------------------------------------------------------------------------- -// Detect system/automated messages that should be collapsed by default. -// These are generated by teamctl.js and contain tool instructions, not -// human-written content, so showing them expanded adds visual noise. +// Detect historical system/automated messages that should be collapsed by default. +// These patterns are kept only for legacy compatibility with old inbox/session rows; +// new runtime behavior must not depend on exact legacy wording. // --------------------------------------------------------------------------- const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [ @@ -139,7 +139,7 @@ const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [ { pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' }, ]; -function getSystemMessageLabel(text: string): string | null { +export function getSystemMessageLabel(text: string): string | null { for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) { if (pattern.test(text)) return label; } diff --git a/src/renderer/components/team/dialogs/AdvancedCliSection.tsx b/src/renderer/components/team/dialogs/AdvancedCliSection.tsx new file mode 100644 index 00000000..de04c5d4 --- /dev/null +++ b/src/renderer/components/team/dialogs/AdvancedCliSection.tsx @@ -0,0 +1,329 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { Popover, PopoverAnchor, PopoverContent } from '@renderer/components/ui/popover'; +import { parseCliArgs, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser'; +import { + AlertTriangle, + CheckCircle2, + ChevronRight, + Clock, + Loader2, + Terminal, + XCircle, +} from 'lucide-react'; + +interface AdvancedCliSectionProps { + teamName: string; + /** All CLI args from parent (model, effort, permissions, resume, etc.) */ + internalArgs: string[]; + worktreeEnabled: boolean; + onWorktreeEnabledChange: (enabled: boolean) => void; + worktreeName: string; + onWorktreeNameChange: (name: string) => void; + customArgs: string; + onCustomArgsChange: (args: string) => void; +} + +/** Infrastructure flags that are dimmed in command preview. */ +const INFRA_FLAGS = new Set([ + '--input-format', + '--output-format', + '--setting-sources', + '--mcp-config', + '--disallowedTools', + '--verbose', +]); + +type ValidationState = 'idle' | 'loading' | 'success' | 'error'; +type TokenType = 'command' | 'visible' | 'infra' | 'custom'; + +/** Map token type → Tailwind color class (pure function, no state dependency). */ +const TOKEN_COLOR_CLASS: Record = { + command: 'text-text', + visible: 'text-text', + infra: 'text-text-muted', + custom: 'text-emerald-400', +}; + +/** Read worktree history from localStorage for a given team. */ +function readWorktreeHistory(teamName: string): string[] { + try { + const raw = localStorage.getItem(`team:worktreeHistory:${teamName}`); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +/** + * Collapsible "Advanced" section for CreateTeamDialog and LaunchTeamDialog. + * Contains: worktree checkbox with history, command preview, custom args + validate. + */ +export const AdvancedCliSection: React.FC = ({ + teamName, + internalArgs, + worktreeEnabled, + onWorktreeEnabledChange, + worktreeName, + onWorktreeNameChange, + customArgs, + onCustomArgsChange, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [validationState, setValidationState] = useState('idle'); + const [validationMessage, setValidationMessage] = useState(null); + const [showHistory, setShowHistory] = useState(false); + + // Read worktree history from localStorage; re-read when teamName changes + const [worktreeHistory, setWorktreeHistory] = useState(() => + readWorktreeHistory(teamName) + ); + useEffect(() => { + setWorktreeHistory(readWorktreeHistory(teamName)); + }, [teamName]); + + // Commit worktree name to history on blur + const commitWorktreeName = useCallback(() => { + const name = worktreeName.trim(); + if (!name) return; + setWorktreeHistory((prev) => { + const next = [name, ...prev.filter((n) => n !== name)].slice(0, 10); + localStorage.setItem(`team:worktreeHistory:${teamName}`, JSON.stringify(next)); + return next; + }); + }, [worktreeName, teamName]); + + // Build command preview tokens + const previewTokens = useMemo(() => { + const tokens: { text: string; type: 'command' | 'visible' | 'infra' | 'custom' }[] = []; + tokens.push({ text: 'claude', type: 'command' }); + + // Process internalArgs: classify each as visible or infra + let i = 0; + while (i < internalArgs.length) { + const arg = internalArgs[i]; + const isInfra = INFRA_FLAGS.has(arg); + const type = isInfra ? 'infra' : 'visible'; + tokens.push({ text: arg, type }); + // Check if next token is the value for this flag (not starting with --) + if (i + 1 < internalArgs.length && !internalArgs[i + 1].startsWith('--')) { + tokens.push({ text: internalArgs[i + 1], type }); + i += 2; + } else { + i += 1; + } + } + + // Worktree + if (worktreeEnabled && worktreeName.trim()) { + tokens.push({ text: '--worktree', type: 'visible' }); + tokens.push({ text: worktreeName.trim(), type: 'visible' }); + } + + // Custom args + const parsed = parseCliArgs(customArgs); + for (const t of parsed) { + tokens.push({ text: t, type: 'custom' }); + } + + return tokens; + }, [internalArgs, worktreeEnabled, worktreeName, customArgs]); + + // Validate handler + const handleValidate = useCallback(async () => { + if (!customArgs.trim()) return; + setValidationState('loading'); + setValidationMessage(null); + try { + const result = await window.electronAPI.teams.validateCliArgs(customArgs); + if (result.valid) { + setValidationState('success'); + setValidationMessage('All flags valid'); + } else { + setValidationState('error'); + const flags = result.invalidFlags ?? []; + const unknown = flags.filter((f) => !PROTECTED_CLI_FLAGS.has(f)); + const protectedOnes = flags.filter((f) => PROTECTED_CLI_FLAGS.has(f)); + const parts: string[] = []; + if (unknown.length > 0) parts.push(`Unknown: ${unknown.join(', ')}`); + if (protectedOnes.length > 0) parts.push(`Protected: ${protectedOnes.join(', ')}`); + setValidationMessage(parts.join(' | ')); + } + } catch (err) { + setValidationState('error'); + setValidationMessage(err instanceof Error ? err.message : 'Validation failed'); + } + }, [customArgs]); + + // Reset validation when custom args change + const handleCustomArgsChange = useCallback( + (value: string) => { + onCustomArgsChange(value); + if (validationState !== 'idle') { + setValidationState('idle'); + setValidationMessage(null); + } + }, + [onCustomArgsChange, validationState] + ); + + const filteredHistory = useMemo( + () => + worktreeHistory.filter( + (name) => name !== worktreeName && (!worktreeName || name.includes(worktreeName)) + ), + [worktreeHistory, worktreeName] + ); + + return ( +
+ {/* Collapsible header */} + + + {isOpen && ( +
+ {/* Worktree */} +
+
+ onWorktreeEnabledChange(value === true)} + /> + +
+ + {worktreeEnabled && ( + 0}> + + onWorktreeNameChange(e.target.value)} + onFocus={() => setShowHistory(true)} + onBlur={() => { + // Delay to allow click on history items + setTimeout(() => { + setShowHistory(false); + commitWorktreeName(); + }, 150); + }} + /> + + e.preventDefault()} + > +
+ + Recent +
+ {filteredHistory.map((name) => ( + + ))} +
+
+ )} +
+ + {/* Command preview */} +
+ + Command preview + +
+ + {previewTokens.map((token, i) => ( + + {token.text} + + ))} + +
+
+ + {/* Custom arguments */} +
+ + Custom arguments + +
+ handleCustomArgsChange(e.target.value)} + /> + {customArgs.trim() && ( + + )} +
+ + {/* Validation result */} + {validationState === 'success' && validationMessage && ( +
+ + {validationMessage} +
+ )} + {validationState === 'error' && validationMessage && ( +
+ {validationMessage.includes('Protected') ? ( + + ) : ( + + )} + {validationMessage} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 31c16f4a..5a079e97 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -31,6 +31,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { getMemberColor } from '@shared/constants/memberColors'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; +import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; import { ProjectPathSelector } from './ProjectPathSelector'; @@ -245,6 +246,21 @@ export const CreateTeamDialog = ({ () => localStorage.getItem('team:lastSelectedEffort') ?? '' ); + // Advanced CLI section state (use teamName-derived key for localStorage) + const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_'; + const [worktreeEnabled, setWorktreeEnabledRaw] = useState(false); + const [worktreeName, setWorktreeNameRaw] = useState(''); + const [customArgs, setCustomArgsRaw] = useState(''); + + // Re-read localStorage when advancedKey changes + useEffect(() => { + const storedEnabled = localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true'; + const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? ''; + setWorktreeEnabledRaw(storedEnabled && Boolean(storedName)); + setWorktreeNameRaw(storedName); + setCustomArgsRaw(localStorage.getItem(`team:lastCustomArgs:${advancedKey}`) ?? ''); + }, [advancedKey]); + const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); localStorage.setItem('team:lastSelectedModel', value); @@ -265,6 +281,23 @@ export const CreateTeamDialog = ({ localStorage.setItem('team:lastSelectedEffort', value); }; + const setWorktreeEnabled = (value: boolean): void => { + setWorktreeEnabledRaw(value); + localStorage.setItem(`team:lastWorktreeEnabled:${advancedKey}`, String(value)); + if (!value) { + setWorktreeNameRaw(''); + localStorage.setItem(`team:lastWorktreeName:${advancedKey}`, ''); + } + }; + const setWorktreeName = (value: string): void => { + setWorktreeNameRaw(value); + localStorage.setItem(`team:lastWorktreeName:${advancedKey}`, value); + }; + const setCustomArgs = (value: string): void => { + setCustomArgsRaw(value); + localStorage.setItem(`team:lastCustomArgs:${advancedKey}`, value); + }; + const resetUIState = (): void => { setLocalError(null); setFieldErrors({}); @@ -496,6 +529,8 @@ export const CreateTeamDialog = ({ model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, skipPermissions, + worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, + extraCliArgs: customArgs.trim() || undefined, }), [ sanitizedTeamName, @@ -508,9 +543,23 @@ export const CreateTeamDialog = ({ effectiveModel, selectedEffort, skipPermissions, + worktreeEnabled, + worktreeName, + customArgs, ] ); + const internalArgs = useMemo(() => { + const args: string[] = []; + args.push('--input-format', 'stream-json', '--output-format', 'stream-json'); + args.push('--verbose', '--setting-sources', 'user,project,local'); + args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite'); + if (skipPermissions) args.push('--dangerously-skip-permissions'); + if (effectiveModel) args.push('--model', effectiveModel); + if (selectedEffort) args.push('--effort', selectedEffort); + return args; + }, [skipPermissions, effectiveModel, selectedEffort]); + const activeError = localError ?? provisioningError; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -839,6 +888,16 @@ export const CreateTeamDialog = ({ /> )} + ) : null} diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 872ad502..c8c918b4 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -24,6 +24,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react'; +import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { ProjectPathSelector } from './ProjectPathSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; @@ -90,6 +91,36 @@ export const LaunchTeamDialog = ({ const [clearContext, setClearContext] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); + // Advanced CLI section state (with localStorage persistence) + const [worktreeEnabled, setWorktreeEnabledRaw] = useState( + () => + localStorage.getItem(`team:lastWorktreeEnabled:${teamName}`) === 'true' && + Boolean(localStorage.getItem(`team:lastWorktreeName:${teamName}`)) + ); + const [worktreeName, setWorktreeNameRaw] = useState( + () => localStorage.getItem(`team:lastWorktreeName:${teamName}`) ?? '' + ); + const [customArgs, setCustomArgsRaw] = useState( + () => localStorage.getItem(`team:lastCustomArgs:${teamName}`) ?? '' + ); + + const setWorktreeEnabled = (value: boolean): void => { + setWorktreeEnabledRaw(value); + localStorage.setItem(`team:lastWorktreeEnabled:${teamName}`, String(value)); + if (!value) { + setWorktreeNameRaw(''); + localStorage.setItem(`team:lastWorktreeName:${teamName}`, ''); + } + }; + const setWorktreeName = (value: string): void => { + setWorktreeNameRaw(value); + localStorage.setItem(`team:lastWorktreeName:${teamName}`, value); + }; + const setCustomArgs = (value: string): void => { + setCustomArgsRaw(value); + localStorage.setItem(`team:lastCustomArgs:${teamName}`, value); + }; + const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); localStorage.setItem('team:lastSelectedModel', value); @@ -284,6 +315,21 @@ export const LaunchTeamDialog = ({ [members, colorMap] ); + const internalArgs = useMemo(() => { + const args: string[] = []; + // Infrastructure (always present, dimmed in preview) + args.push('--input-format', 'stream-json', '--output-format', 'stream-json'); + args.push('--verbose', '--setting-sources', 'user,project,local'); + args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite'); + // User-visible + if (skipPermissions) args.push('--dangerously-skip-permissions'); + const model = computeEffectiveTeamModel(selectedModel, extendedContext); + if (model) args.push('--model', model); + if (selectedEffort) args.push('--effort', selectedEffort); + if (!clearContext) args.push('--resume', ''); + return args; + }, [skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]); + const activeError = localError ?? provisioningError; const handleSubmit = (): void => { @@ -304,6 +350,8 @@ export const LaunchTeamDialog = ({ effort: (selectedEffort as EffortLevel) || undefined, clearContext: clearContext || undefined, skipPermissions, + worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, + extraCliArgs: customArgs.trim() || undefined, }); resetFormState(); onClose(); @@ -498,6 +546,17 @@ export const LaunchTeamDialog = ({ )} + + {activeError ? ( diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 616ef726..4bf8072c 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -64,6 +64,7 @@ import type { UpdateKanbanPatch, } from './team'; import type { TerminalAPI } from './terminal'; +import type { CliArgsValidationResult } from '../utils/cliArgsParser'; import type { WaterfallData } from './visualization'; import type { ConversationGroup, @@ -518,6 +519,7 @@ export interface TeamsAPI { allow: boolean, message?: string ) => Promise; + validateCliArgs: (rawArgs: string) => Promise; onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 359767cd..d891463a 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -313,6 +313,10 @@ export interface TeamLaunchRequest { clearContext?: boolean; /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ skipPermissions?: boolean; + /** Worktree name — CLI: --worktree . */ + worktree?: string; + /** Raw custom CLI args string, shell-split and appended to CLI command. */ + extraCliArgs?: string; } export interface TeamLaunchResponse { @@ -396,6 +400,10 @@ export interface TeamCreateRequest { effort?: EffortLevel; /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ skipPermissions?: boolean; + /** Worktree name — CLI: --worktree . */ + worktree?: string; + /** Raw custom CLI args string, shell-split and appended to CLI command. */ + extraCliArgs?: string; } export interface TeamCreateConfigRequest { diff --git a/src/shared/utils/cliArgsParser.ts b/src/shared/utils/cliArgsParser.ts new file mode 100644 index 00000000..5adae22c --- /dev/null +++ b/src/shared/utils/cliArgsParser.ts @@ -0,0 +1,120 @@ +/** + * CLI argument parsing and validation utilities. + * + * Used for: + * - Parsing user-entered custom CLI args into an array for spawn() + * - Extracting known flags from `claude --help` output for validation + * - Identifying which user-entered flags are invalid + */ + +/** Результат валидации пользовательских аргументов через `claude --help`. */ +export interface CliArgsValidationResult { + valid: boolean; + invalidFlags?: string[]; +} + +/** + * Набор CLI-флагов, которые управляются приложением автоматически. + * Если пользователь указал один из них в custom args — Validate покажет warning. + */ +export const PROTECTED_CLI_FLAGS = new Set([ + '--input-format', + '--output-format', + '--setting-sources', + '--mcp-config', + '--disallowedTools', + '--verbose', +]); + +/** + * Shell-like split: разбивает строку на токены, учитывая кавычки. + * + * - Поддерживает одинарные и двойные кавычки + * - НЕ обрабатывает backslash-escaping (не нужно для CLI-флагов) + * - Множественные пробелы/табы игнорируются + * + * @example + * parseCliArgs('--verbose --max-turns 5') // ['--verbose', '--max-turns', '5'] + * parseCliArgs('--message "hello world"') // ['--message', 'hello world'] + * parseCliArgs("--message 'it works'") // ['--message', 'it works'] + * parseCliArgs(undefined) // [] + */ +export function parseCliArgs(raw: string | undefined): string[] { + if (!raw) return []; + + const result: string[] = []; + let current = ''; + let inSingleQuote = false; + let inDoubleQuote = false; + let hasQuote = false; + + for (const ch of raw) { + if (ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + hasQuote = true; + continue; + } + if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + hasQuote = true; + continue; + } + if ((ch === ' ' || ch === '\t') && !inSingleQuote && !inDoubleQuote) { + if (current.length > 0 || hasQuote) { + result.push(current); + current = ''; + hasQuote = false; + } + continue; + } + current += ch; + } + + if (current.length > 0 || hasQuote) { + result.push(current); + } + + return result; +} + +/** + * Извлекает все CLI-флаги из вывода `claude --help`. + * + * Парсит: + * - Long flags: `--model`, `--max-turns`, `--dangerously-skip-permissions` + * - Short flags: `-p`, `-w`, `-m` + * + * Regex осторожно выбирает только флаги в "позиции флага" (после пробела/начала строки), + * чтобы не ловить дефисы из обычного текста. + */ +export function extractFlagsFromHelp(helpOutput: string): Set { + const flags = new Set(); + + // Long flags: --word-word-word (после пробела, начала строки, или запятой) + const longFlagRegex = /(?:^|[\s,])(-{2}[a-zA-Z][a-zA-Z0-9-]*)/gm; + let match: RegExpExecArray | null; + while ((match = longFlagRegex.exec(helpOutput)) !== null) { + flags.add(match[1]); + } + + // Short flags: -X (одна буква, после пробела/начала строки/запятой) + const shortFlagRegex = /(?:^|[\s,])(-[a-zA-Z])\b/gm; + while ((match = shortFlagRegex.exec(helpOutput)) !== null) { + flags.add(match[1]); + } + + return flags; +} + +/** + * Извлекает только флаги (начинающиеся с `-`) из строки пользовательских аргументов. + * + * @example + * extractUserFlags('--verbose --max-turns 5 foo') // ['--verbose', '--max-turns'] + * extractUserFlags('-p -w') // ['-p', '-w'] + * extractUserFlags('') // [] + */ +export function extractUserFlags(raw: string): string[] { + const tokens = parseCliArgs(raw); + return tokens.filter((token) => token.startsWith('-')); +} diff --git a/src/shared/utils/reviewState.ts b/src/shared/utils/reviewState.ts index 4706cedf..ae649bde 100644 --- a/src/shared/utils/reviewState.ts +++ b/src/shared/utils/reviewState.ts @@ -40,3 +40,4 @@ export function isApprovedTask(task: ReviewStateLike): boolean { export function isReviewTask(task: ReviewStateLike): boolean { return getReviewStateFromTask(task) === 'review'; } + diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 07f85bb6..8b861a0f 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -56,12 +56,17 @@ declare module 'agent-teams-controller' { listProcesses(): unknown[]; } + export interface ControllerMaintenanceApi { + reconcileArtifacts(flags?: Record): unknown; + } + export interface AgentTeamsController { tasks: ControllerTaskApi; kanban: ControllerKanbanApi; review: ControllerReviewApi; messages: ControllerMessageApi; processes: ControllerProcessApi; + maintenance: ControllerMaintenanceApi; } export function createController(options: ControllerContextOptions): AgentTeamsController; diff --git a/test/main/services/team/TaskBoundaryParser.test.ts b/test/main/services/team/TaskBoundaryParser.test.ts new file mode 100644 index 00000000..5814b873 --- /dev/null +++ b/test/main/services/team/TaskBoundaryParser.test.ts @@ -0,0 +1,159 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import * as fs from 'fs/promises'; + +import { TaskBoundaryParser } from '../../../../src/main/services/team/TaskBoundaryParser'; + +describe('TaskBoundaryParser', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('detects MCP task boundaries for modern runtime sessions', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); + const jsonlPath = path.join(tmpDir, 'mcp.jsonl'); + await fs.writeFile( + jsonlPath, + [ + JSON.stringify({ + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'task_start', + input: { taskId: 'task-123' }, + }, + ], + }, + }), + JSON.stringify({ + timestamp: '2026-03-01T10:10:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-2', + name: 'task_complete', + input: { taskId: 'task-123' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); + + expect(result.detectedMechanism).toBe('mcp'); + expect(result.boundaries).toHaveLength(2); + expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']); + expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true); + }); + + it('falls back to legacy teamctl bash parsing for historical logs', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); + const jsonlPath = path.join(tmpDir, 'teamctl.jsonl'); + await fs.writeFile( + jsonlPath, + [ + JSON.stringify({ + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'Bash', + input: { command: 'node "teamctl.js" --team demo task start 123' }, + }, + ], + }, + }), + JSON.stringify({ + timestamp: '2026-03-01T10:10:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-2', + name: 'Bash', + input: { command: 'node "teamctl.js" --team demo task set-status 123 completed' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); + + expect(result.detectedMechanism).toBe('teamctl'); + expect(result.boundaries).toHaveLength(2); + expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']); + expect(result.boundaries.every((entry) => entry.mechanism === 'teamctl')).toBe(true); + }); + + it('prefers structured mechanisms over legacy teamctl in mixed logs', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); + const jsonlPath = path.join(tmpDir, 'mixed.jsonl'); + await fs.writeFile( + jsonlPath, + [ + JSON.stringify({ + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'task_start', + input: { taskId: 'task-123' }, + }, + ], + }, + }), + JSON.stringify({ + timestamp: '2026-03-01T10:05:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-2', + name: 'Bash', + input: { command: 'node "teamctl.js" --team demo task complete 123' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); + + expect(result.detectedMechanism).toBe('mcp'); + }); +}); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 2db057c4..c39efff3 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; -import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team'; +import type { TeamTask } from '../../../../src/shared/types/team'; describe('TeamDataService', () => { it('keeps getTeamData read-only and skips kanban garbage-collect', async () => { @@ -47,52 +47,17 @@ describe('TeamDataService', () => { expect(order).toEqual(['tasks']); }); - it('reconciles linked comments outside getTeamData and skips automated notifications', async () => { - const tasks: TeamTask[] = [ - { - id: '12', - subject: 'Task', - status: 'pending', - }, - ]; - - const addComment = vi.fn(async () => { - throw new Error('Should not be called'); - }); - - const messages: InboxMessage[] = [ - { - from: 'team-lead', - to: 'alice', - summary: 'Comment on #12', - messageId: 'm1', - timestamp: new Date().toISOString(), - read: false, - text: - 'Comment on task #12 "Task":\n\nHello\n\n' + - '\n' + - 'Reply to this comment using:\n' + - 'node "tool.js" --team my-team task comment 12 --text "..." --from "alice"\n' + - '', - }, - ]; - + it('delegates explicit reconcile to controller maintenance API', async () => { + const reconcileArtifacts = vi.fn(); const service = new TeamDataService( { listTeams: vi.fn(), getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })), } as never, - { - getTasks: vi.fn(async () => tasks), - } as never, - { - listInboxNames: vi.fn(async () => []), - getMessages: vi.fn(async () => messages), - } as never, {} as never, - { - addComment, - } as never, + {} as never, + {} as never, + {} as never, { resolveMembers: vi.fn(() => []), } as never, @@ -109,32 +74,27 @@ describe('TeamDataService', () => { } as never, () => ({ - tasks: { - addTaskComment: addComment, + maintenance: { + reconcileArtifacts, }, }) as never ); await service.reconcileTeamArtifacts('my-team'); - expect(addComment).not.toHaveBeenCalled(); + expect(reconcileArtifacts).toHaveBeenCalledWith({ reason: 'file-watch' }); }); - it('skips reconcile writes when tasks fail to load', async () => { - const garbageCollect = vi.fn(async () => undefined); + it('surfaces controller reconcile failures', async () => { + const reconcileArtifacts = vi.fn(() => { + throw new Error('reconcile failed'); + }); const service = new TeamDataService( { listTeams: vi.fn(), getConfig: vi.fn(async () => ({ name: 'My team', members: [] })), } as never, - { - getTasks: vi.fn(async () => { - throw new Error('tasks failed'); - }), - } as never, - { - listInboxNames: vi.fn(async () => []), - getMessages: vi.fn(async () => []), - } as never, + {} as never, + {} as never, {} as never, {} as never, { @@ -142,12 +102,20 @@ describe('TeamDataService', () => { } as never, { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), - garbageCollect, - } as never + garbageCollect: vi.fn(async () => undefined), + } as never, + {} as never, + {} as never, + {} as never, + () => + ({ + maintenance: { + reconcileArtifacts, + }, + }) as never ); - await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('tasks failed'); - expect(garbageCollect).not.toHaveBeenCalled(); + await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('reconcile failed'); }); it('includes projectPath from config when creating a task', async () => { diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index c468ffb0..8a83b01a 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -588,4 +588,65 @@ describe('TeamMemberLogsFinder', () => { logsForA.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') ).toBe(false); }); + + it('detects structured task markers while keeping legacy teamctl matching as fallback', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-marker-logs-')); + + const structuredPath = path.join(tmpDir, 'structured.jsonl'); + await fs.writeFile( + structuredPath, + JSON.stringify({ + timestamp: '2026-01-01T00:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'task_start', + input: { teamName: 'demo', taskId: 'task-42' }, + }, + ], + }, + }) + '\n', + 'utf8' + ); + + const legacyPath = path.join(tmpDir, 'legacy.jsonl'); + await fs.writeFile( + legacyPath, + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Bash', + input: { command: 'node \"teamctl.js\" --team demo task start task-42' }, + }, + ], + }, + }) + '\n', + 'utf8' + ); + + const noisePath = path.join(tmpDir, 'noise.jsonl'); + await fs.writeFile( + noisePath, + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'No task markers here' }] }, + }) + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + + await expect(finder.hasTaskUpdateMarker(structuredPath, 'task-42')).resolves.toBe(true); + await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(true); + await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false); + }); }); diff --git a/test/shared/utils/cliArgsParser.test.ts b/test/shared/utils/cliArgsParser.test.ts new file mode 100644 index 00000000..e31236f7 --- /dev/null +++ b/test/shared/utils/cliArgsParser.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractFlagsFromHelp, + extractUserFlags, + parseCliArgs, + PROTECTED_CLI_FLAGS, +} from '@shared/utils/cliArgsParser'; + +describe('parseCliArgs', () => { + it('returns empty array for undefined', () => { + expect(parseCliArgs(undefined)).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(parseCliArgs('')).toEqual([]); + }); + + it('splits simple flags and values', () => { + expect(parseCliArgs('--verbose --max-turns 5')).toEqual(['--verbose', '--max-turns', '5']); + }); + + it('handles double-quoted strings', () => { + expect(parseCliArgs('--message "hello world"')).toEqual(['--message', 'hello world']); + }); + + it('handles single-quoted strings', () => { + expect(parseCliArgs("--message 'it works'")).toEqual(['--message', 'it works']); + }); + + it('trims leading/trailing whitespace', () => { + expect(parseCliArgs(' --verbose ')).toEqual(['--verbose']); + }); + + it('handles multiple consecutive spaces', () => { + expect(parseCliArgs('--foo --bar baz')).toEqual(['--foo', '--bar', 'baz']); + }); + + it('handles tabs as separators', () => { + expect(parseCliArgs('--foo\t--bar')).toEqual(['--foo', '--bar']); + }); + + it('handles mixed quotes', () => { + expect(parseCliArgs(`--a "hello 'inner'" --b 'world "nested"'`)).toEqual([ + '--a', + "hello 'inner'", + '--b', + 'world "nested"', + ]); + }); + + it('handles short flags', () => { + expect(parseCliArgs('-p "prompt text" -w name')).toEqual(['-p', 'prompt text', '-w', 'name']); + }); + + it('handles flag=value format', () => { + expect(parseCliArgs('--model=opus-4')).toEqual(['--model=opus-4']); + }); + + it('handles empty quoted strings', () => { + expect(parseCliArgs('--value ""')).toEqual(['--value', '']); + }); +}); + +describe('extractFlagsFromHelp', () => { + const SAMPLE_HELP = ` +Usage: claude [options] [prompt] + +Options: + -p, --print Print response without interactive mode + -w, --worktree [name] Run in a git worktree + --model Specify the model to use + --max-turns Maximum conversation turns + --verbose Enable verbose logging + --dangerously-skip-permissions Skip permission checks + --input-format Input format (text, stream-json) + --output-format Output format + --no-session-persistence Don't persist session + -h, --help Display this help + -V, --version Display version + +For more information, visit https://docs.anthropic.com +This is a non-interactive tool for automated workflows. + `; + + it('extracts long flags', () => { + const flags = extractFlagsFromHelp(SAMPLE_HELP); + expect(flags.has('--model')).toBe(true); + expect(flags.has('--max-turns')).toBe(true); + expect(flags.has('--verbose')).toBe(true); + expect(flags.has('--dangerously-skip-permissions')).toBe(true); + expect(flags.has('--input-format')).toBe(true); + expect(flags.has('--output-format')).toBe(true); + expect(flags.has('--no-session-persistence')).toBe(true); + expect(flags.has('--worktree')).toBe(true); + }); + + it('extracts short flags', () => { + const flags = extractFlagsFromHelp(SAMPLE_HELP); + expect(flags.has('-p')).toBe(true); + expect(flags.has('-w')).toBe(true); + expect(flags.has('-h')).toBe(true); + expect(flags.has('-V')).toBe(true); + }); + + it('does not match hyphens in regular text', () => { + const flags = extractFlagsFromHelp(SAMPLE_HELP); + // "non-interactive" should not produce --non or -n from hyphenated words + expect(flags.has('--non')).toBe(false); + }); + + it('returns empty set for empty input', () => { + expect(extractFlagsFromHelp('').size).toBe(0); + }); +}); + +describe('extractUserFlags', () => { + it('extracts flags from mixed input', () => { + expect(extractUserFlags('--verbose --max-turns 5 foo')).toEqual([ + '--verbose', + '--max-turns', + ]); + }); + + it('extracts short flags', () => { + expect(extractUserFlags('-p -w')).toEqual(['-p', '-w']); + }); + + it('returns empty for empty string', () => { + expect(extractUserFlags('')).toEqual([]); + }); + + it('returns empty when no flags present', () => { + expect(extractUserFlags('hello world')).toEqual([]); + }); +}); + +describe('PROTECTED_CLI_FLAGS', () => { + it('contains expected flags', () => { + expect(PROTECTED_CLI_FLAGS.has('--input-format')).toBe(true); + expect(PROTECTED_CLI_FLAGS.has('--output-format')).toBe(true); + expect(PROTECTED_CLI_FLAGS.has('--mcp-config')).toBe(true); + expect(PROTECTED_CLI_FLAGS.has('--disallowedTools')).toBe(true); + expect(PROTECTED_CLI_FLAGS.has('--verbose')).toBe(true); + }); + + it('does not contain user-facing flags', () => { + expect(PROTECTED_CLI_FLAGS.has('--model')).toBe(false); + expect(PROTECTED_CLI_FLAGS.has('--effort')).toBe(false); + expect(PROTECTED_CLI_FLAGS.has('--worktree')).toBe(false); + }); +});