diff --git a/README.md b/README.md index bc9eb1e9..0c7e06dd 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,7 @@

Claude Agent Teams UI

- Terminal tells you nothing. This shows you everything. -
- You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee. + You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee.

diff --git a/bin/kill-dev.js b/bin/kill-dev.js new file mode 100644 index 00000000..9d4812e8 --- /dev/null +++ b/bin/kill-dev.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { spawnSync } from 'child_process'; + +const isWindows = process.platform === 'win32'; + +if (isWindows) { + const r = spawnSync('taskkill', ['/F', '/IM', 'electron.exe'], { + stdio: 'inherit', + shell: true, + }); + if (r.status != null && r.status !== 0 && r.status !== 128 && r.signal == null) { + process.exitCode = 1; + } +} else { + const r = spawnSync('pkill', ['-f', 'electron-vite|electron \\.'], { stdio: 'inherit' }); + if (r.status != null && r.status !== 0 && r.status !== 1 && r.signal == null) { + process.exitCode = 1; + } +} +console.log('Done'); diff --git a/package.json b/package.json index 5b809d3f..b2e39cf9 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "main": "dist-electron/main/index.cjs", "scripts": { "dev": "electron-vite dev", - "dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'", + "dev:kill": "node bin/kill-dev.js", "build": "electron-vite build", "dist": "electron-builder --mac --win --linux", "dist:mac": "electron-builder --mac --publish always", diff --git a/src/main/index.ts b/src/main/index.ts index 115d1fca..9aa6e4c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -311,6 +311,14 @@ function initializeServices(): void { void new TeamAgentToolsInstaller().ensureInstalled(); httpServer = new HttpServer(); + // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). + teamProvisioningService.setTeamChangeEmitter((event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(TEAM_CHANGE, event); + } + httpServer?.broadcast('team-change', event); + }); + // Initialize IPC handlers with registry initializeIpcHandlers( contextRegistry, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index af203e8d..62218c45 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -182,16 +182,58 @@ async function handleGetData( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('getData', async () => { - const data = await getTeamDataService().getTeamData(validated.value!); - const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!); + const tn = validated.value!; + const data = await getTeamDataService().getTeamData(tn); + const provisioning = getTeamProvisioningService(); + const isAlive = provisioning.isTeamAlive(tn); + if (isAlive) { - try { - await getTeamProvisioningService().relayLeadInboxMessages(validated.value!); - } catch { - // Best-effort: never fail getData due to relay issues - } + // Fire-and-forget: relay can take time (waits for lead reply). + void provisioning.relayLeadInboxMessages(tn).catch(() => undefined); } - return { ...data, isAlive }; + + const live = provisioning.getLiveLeadProcessMessages(tn); + if (live.length === 0) { + return { ...data, isAlive }; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const leadSessionTextFingerprints = new Set(); + for (const msg of data.messages) { + if ((msg as { source?: unknown }).source !== 'lead_session') continue; + if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; + leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); + } + + const keyFor = (m: { + messageId?: string; + timestamp: string; + from: string; + text: string; + }): string => { + if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { + return m.messageId; + } + return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; + }; + + const merged: typeof data.messages = []; + const seen = new Set(); + for (const msg of [...data.messages, ...live]) { + if ((msg as { source?: unknown }).source === 'lead_process') { + const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; + if (leadSessionTextFingerprints.has(fp)) { + continue; + } + } + const key = keyFor(msg); + if (seen.has(key)) continue; + seen.add(key); + merged.push(msg); + } + merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + + return { ...data, isAlive, messages: merged }; }); } @@ -242,7 +284,9 @@ async function handleUpdateConfig( } function isProvisioningTeamName(teamName: string): boolean { - return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(teamName) && teamName.length <= 64; + if (teamName.length > 64) return false; + const parts = teamName.split('-'); + return parts.every((p) => /^[a-z0-9]+$/.test(p)); } async function validateProvisioningRequest( diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index 83d1b6db..05f08390 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -12,12 +12,21 @@ import { createLogger } from '@shared/utils/logger'; import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron'; import * as fs from 'fs'; -import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; +import { + type ClaudeMdFileInfo, + readAgentConfigs, + readAllClaudeMdFiles, + readDirectoryClaudeMd, +} from '../services'; import type { AgentConfig } from '@shared/types/api'; const logger = createLogger('IPC:utility'); -import { validateFilePath, validateOpenPath } from '../utils/pathValidation'; +import { + validateFilePath, + validateOpenPath, + validateOpenPathUserSelected, +} from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; /** @@ -96,15 +105,19 @@ async function handleShellOpenExternal( * Handler for 'shell:openPath' IPC call. * Opens a folder or file in the system's default application (Finder on macOS). * Validates path security before opening. + * When userSelectedFromDialog is true, path was chosen via system folder picker — + * only sensitive-pattern checks apply, not project/claude directory restriction. */ async function handleShellOpenPath( _event: IpcMainInvokeEvent, targetPath: string, - projectRoot?: string + projectRoot?: string, + userSelectedFromDialog?: boolean ): Promise<{ success: boolean; error?: string }> { try { - // Validate path security - const validation = validateOpenPath(targetPath, projectRoot ?? null); + const validation = userSelectedFromDialog + ? validateOpenPathUserSelected(targetPath) + : validateOpenPath(targetPath, projectRoot ?? null); if (!validation.valid) { logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`); return { success: false, error: validation.error }; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index adc55287..ade6030a 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -469,7 +469,7 @@ export class TeamDataService { for (const msg of messages) { if (!msg.messageId || !msg.summary || msg.from === 'user') continue; - if (msg.source === 'lead_session') continue; + if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; const textKey = `${msg.from}\0${msg.text}`; if (processedTexts.has(textKey)) continue; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 645eceb6..3f53f634 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -24,6 +24,8 @@ import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { + InboxMessage, + TeamChangeEvent, TeamCreateRequest, TeamCreateResponse, TeamLaunchRequest, @@ -107,6 +109,14 @@ interface ProvisioningRun { waitingTasksSince: number | null; provisioningComplete: boolean; isLaunch: boolean; + leadRelayCapture: { + leadName: string; + startedAt: string; + textParts: string[]; + resolve: (text: string) => void; + reject: (error: string) => void; + timeoutHandle: NodeJS.Timeout; + } | null; } type ProvisioningAuthSource = @@ -242,18 +252,18 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string { function buildTaskStatusProtocol(teamName: string): string { return `MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 1. Use this command to mark task started: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task start + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start 2. Use this command to mark task completed BEFORE sending your final reply: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task complete + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete 3. If you are asked to review and task is accepted, move it to APPROVED (not DONE): - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review approve + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review approve 4. If review fails and changes are needed: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review request-changes --comment \\"\\" + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes --comment "" 5. NEVER skip status updates. A task is NOT done until completed status is written. 6. To reply to a comment on a task: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment --text \\"\\" --from \\"\\" + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from "" 7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment --text \\"

\\" --from \\"\\" + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from "" Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. 8. When sending a message about a specific task, include # in your SendMessage summary field for traceability. Failure to follow this protocol means the task board will show incorrect status.`; @@ -278,11 +288,19 @@ Goal: Provision a Claude Code agent team with live teammates. ${userPromptBlock} Constraints: - Do NOT call TeamDelete under any circumstances. -- Do NOT use TodoWrite — use TaskCreate for tasks. +- Do NOT use TodoWrite. - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT shut down, terminate, or clean up the team or its members. - Keep assistant text minimal. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. +- Keep the task board high-signal: avoid creating tasks for trivial micro-items. +- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task). +- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. + +Communication protocol (CRITICAL — you are running headless, no one sees your text output): +- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. +- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. +- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). Task board operations — use teamctl.js via Bash: - Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" @@ -297,11 +315,16 @@ Steps (execute in this exact order): - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" - - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then wait for task assignments. + - prompt: + You are {name}, a {role} on team "${displayName}" (${request.teamName}). + Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. + Then wait for task assignments. -${taskProtocol}" + ${taskProtocol} -3) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. +3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations"). + - Prefer fewer, broader tasks over many micro-tasks. + - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. 4) After all steps, output a short summary. @@ -329,11 +352,19 @@ Goal: Reconnect with existing team "${request.teamName}". ${userPromptBlock} Constraints: - Do NOT call TeamDelete under any circumstances. -- Do NOT use TodoWrite — use TaskCreate for tasks. +- Do NOT use TodoWrite. - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT shut down, terminate, or clean up the team or its members. - Keep assistant text minimal. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. +- Keep the task board high-signal: avoid creating tasks for trivial micro-items. +- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task). +- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. + +Communication protocol (CRITICAL — you are running headless, no one sees your text output): +- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. +- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. +- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). Task board operations — use teamctl.js via Bash: - Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" @@ -343,17 +374,22 @@ Steps (execute in this exact order): 1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state. -2) Read the task list via TaskList — understand pending work. +2) Read tasks from ~/.claude/tasks/${request.teamName}/ (JSON files) and kanban state from ~/.claude/teams/${request.teamName}/kanban-state.json — understand pending work. 3) Spawn each existing member as a live teammate using the Task tool: - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" - - prompt: "You are {name}, a {role} on team \\"${request.teamName}\\". The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then check TaskList for pending work and resume. + - prompt: + You are {name}, a {role} on team "${request.teamName}". + The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. + Then resume any pending work you own (if any) and wait for new assignments. -${taskProtocol}" + ${taskProtocol} -4) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. +4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations"). + - Prefer fewer, broader tasks over many micro-tasks. + - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. 5) After all steps, output a short summary. @@ -449,6 +485,8 @@ export class TeamProvisioningService { private readonly leadInboxRelayInFlight = new Map>(); private readonly relayedLeadInboxMessageIds = new Map>(); private readonly relayedLeadInboxFallbackKeys = new Map>(); + private readonly liveLeadProcessMessages = new Map(); + private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -456,6 +494,14 @@ export class TeamProvisioningService { private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() ) {} + setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void { + this.teamChangeEmitter = emitter; + } + + getLiveLeadProcessMessages(teamName: string): InboxMessage[] { + return [...(this.liveLeadProcessMessages.get(teamName) ?? [])]; + } + async warmup(): Promise { try { const claudePath = await ClaudeBinaryResolver.resolve(); @@ -611,6 +657,7 @@ export class TeamProvisioningService { provisioningComplete: false, isLaunch: false, fsPhase: 'waiting_config', + leadRelayCapture: null, progress: { runId, teamName: request.teamName, @@ -878,6 +925,7 @@ export class TeamProvisioningService { provisioningComplete: false, isLaunch: true, fsPhase: 'waiting_members', + leadRelayCapture: null, progress: { runId, teamName: request.teamName, @@ -1177,9 +1225,28 @@ export class TeamProvisioningService { }), ].join('\n'); + const captureTimeoutMs = 60_000; + const capturePromise = new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + reject(new Error('Timed out waiting for lead reply')); + }, captureTimeoutMs); + run.leadRelayCapture = { + leadName, + startedAt: nowIso(), + textParts: [], + resolve, + reject, + timeoutHandle, + }; + }); + try { await this.sendMessageToTeam(teamName, message); } catch { + if (run.leadRelayCapture) { + clearTimeout(run.leadRelayCapture.timeoutHandle); + run.leadRelayCapture = null; + } return 0; } @@ -1199,6 +1266,36 @@ export class TeamProvisioningService { // Best-effort: relay succeeded; marking read failed. } + let replyText: string | null = null; + try { + replyText = (await capturePromise).trim() || null; + } catch { + // ignore + } finally { + if (run.leadRelayCapture) { + clearTimeout(run.leadRelayCapture.timeoutHandle); + run.leadRelayCapture = null; + } + } + + if (replyText) { + this.pushLiveLeadProcessMessage(teamName, { + from: leadName, + to: 'user', + text: replyText, + timestamp: nowIso(), + read: true, + summary: 'Lead reply', + messageId: `lead-process-${runId}-${Date.now()}`, + source: 'lead_process', + }); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName, + detail: 'lead-process-reply', + }); + } + return batch.length; })(); @@ -1297,13 +1394,23 @@ export class TeamProvisioningService { return next; } + private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { + const MAX = 100; + const list = this.liveLeadProcessMessages.get(teamName) ?? []; + list.push(message); + if (list.length > MAX) { + list.splice(0, list.length - MAX); + } + this.liveLeadProcessMessages.set(teamName, list); + } + /** * Stop the running process for a team. No-op if team is not running. */ stopTeam(teamName: string): void { const runId = this.activeByTeam.get(teamName); if (!runId) { - throw new Error(`No active process for team "${teamName}"`); + return; } const run = this.runs.get(runId); if (!run) { @@ -1336,6 +1443,9 @@ export class TeamProvisioningService { if (textParts.length > 0) { const text = textParts.join(''); logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); + if (run.leadRelayCapture) { + run.leadRelayCapture.textParts.push(text); + } } } @@ -1343,6 +1453,11 @@ export class TeamProvisioningService { const subtype = msg.subtype as string | undefined; if (subtype === 'success') { logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`); + if (run.leadRelayCapture) { + const capture = run.leadRelayCapture; + const combined = capture.textParts.join('').trim(); + capture.resolve(combined); + } if (!run.provisioningComplete) { void this.handleProvisioningTurnComplete(run); } @@ -1350,6 +1465,9 @@ export class TeamProvisioningService { const errorMsg = typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown'); logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`); + if (run.leadRelayCapture) { + run.leadRelayCapture.reject(errorMsg); + } if (!run.provisioningComplete) { const progress = updateProgress( run, @@ -1454,6 +1572,7 @@ export class TeamProvisioningService { this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.relayedLeadInboxFallbackKeys.delete(run.teamName); + this.liveLeadProcessMessages.delete(run.teamName); } /** diff --git a/src/main/services/team/inboxLock.ts b/src/main/services/team/inboxLock.ts index 96ce677c..a7a79e33 100644 --- a/src/main/services/team/inboxLock.ts +++ b/src/main/services/team/inboxLock.ts @@ -1,19 +1,19 @@ -const writeLocks = new Map>(); +const WRITE_LOCKS = new Map>(); export async function withInboxLock(inboxPath: string, fn: () => Promise): Promise { - const prev = writeLocks.get(inboxPath) ?? Promise.resolve(); + const prev = WRITE_LOCKS.get(inboxPath) ?? Promise.resolve(); let release!: () => void; const mine = new Promise((resolve) => { release = resolve; }); - writeLocks.set(inboxPath, mine); + WRITE_LOCKS.set(inboxPath, mine); await prev; try { return await fn(); } finally { release(); - if (writeLocks.get(inboxPath) === mine) { - writeLocks.delete(inboxPath); + if (WRITE_LOCKS.get(inboxPath) === mine) { + WRITE_LOCKS.delete(inboxPath); } } } diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 2a92463c..dc9c6d34 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -198,6 +198,45 @@ export function validateFilePath( return { valid: true, normalizedPath }; } +/** + * Validates a path for opening when it was explicitly chosen by the user + * via the system folder picker. Only checks sensitive patterns, not + * allowed-directories (project / ~/.claude). + * + * @param targetPath - The path to open + * @returns Validation result + */ +export function validateOpenPathUserSelected(targetPath: string): PathValidationResult { + if (!targetPath || typeof targetPath !== 'string') { + return { valid: false, error: 'Invalid path' }; + } + + const expandedPath = targetPath.startsWith('~') + ? path.join(os.homedir(), targetPath.slice(1)) + : targetPath; + + const normalizedPath = path.resolve(path.normalize(expandedPath)); + + if (!path.isAbsolute(normalizedPath)) { + return { valid: false, error: 'Path must be absolute' }; + } + + if (matchesSensitivePattern(normalizedPath)) { + return { valid: false, error: 'Cannot open sensitive files' }; + } + + const realTargetPath = resolveRealPathIfExists(normalizedPath); + if (realTargetPath) { + const isWindows = process.platform === 'win32'; + const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows); + if (matchesSensitivePattern(normalizedRealTarget)) { + return { valid: false, error: 'Cannot open sensitive files' }; + } + } + + return { valid: true, normalizedPath }; +} + /** * Validates a path for shell:openPath operation. * More permissive than file reading - allows opening project directories diff --git a/src/preload/index.ts b/src/preload/index.ts index 95821a82..99b3533f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -388,8 +388,8 @@ const electronAPI: ElectronAPI = { }, // Shell operations - openPath: (targetPath: string, projectRoot?: string) => - ipcRenderer.invoke('shell:openPath', targetPath, projectRoot), + openPath: (targetPath: string, projectRoot?: string, userSelectedFromDialog?: boolean) => + ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog), openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), // Window controls (when title bar is hidden, e.g. Windows / Linux) diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 3189abdb..3e57503b 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -214,6 +214,40 @@ const RepositoryCard = ({ · {lastActivity} + + {/* Tasks progress bar */} + {taskCounts && + (() => { + const pending = taskCounts.pending ?? 0; + const inProgress = taskCounts.inProgress ?? 0; + const completed = taskCounts.completed ?? 0; + const totalTasks = pending + inProgress + completed; + if (totalTasks === 0) return null; + const completedRatio = completed / totalTasks; + const progressPercent = Math.round(completedRatio * 100); + return ( +
+
+
+
+
+ + {completed}/{totalTasks} + +
+
+ ); + })()} ); }; @@ -250,7 +284,7 @@ const NewProjectCard = (): React.JSX.Element => { } // No match found - open the folder in file manager as fallback - const result = await api.openPath(selectedPath); + const result = await api.openPath(selectedPath, undefined, true); if (!result.success) { logger.error('Failed to open folder:', result.error); } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index f6f15bf7..dfd91893 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -827,6 +827,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele await sendTeamMessage(teamName, { member, text, summary }); } catch { setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; const next = { ...prev }; delete next[member]; return next; diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index b80e51e0..aa48443b 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -250,6 +250,8 @@ export const TeamListView = (): React.JSX.Element => { try { await api.teams.stop(teamName); setAliveTeams((prev) => prev.filter((n) => n !== teamName)); + } catch (err) { + console.error('Failed to stop team:', err); } finally { setStoppingTeamName(null); } @@ -415,12 +417,26 @@ export const TeamListView = (): React.JSX.Element => { {filteredTeams.map((team) => { const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); const teamColorSet = team.color ? getTeamColorSet(team.color) : null; + const matchesCurrentProject = + !!currentProjectPath && + (() => { + if (team.projectPath && normalizePath(team.projectPath) === currentProjectPath) + return true; + return ( + team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? + false + ); + })(); return (
{/* Header — clickable when system message to toggle expand */} -
setIsExpanded((v) => !v) : undefined} onKeyDown={ systemLabel @@ -216,7 +216,7 @@ export const ActivityItem = ({ /> ) : null} - {message.source === 'lead_session' ? ( + {message.source === 'lead_session' || message.source === 'lead_process' ? ( ) : ( @@ -275,6 +275,10 @@ export const ActivityItem = ({ session + ) : message.source === 'lead_process' ? ( + + live + ) : null} {/* Recipient — badge like sender, clickable to open member popup */} @@ -370,7 +374,7 @@ export const ActivityItem = ({ {timestamp}
-
+ {/* Content — collapsed for system messages, expanded for others */} {isExpanded ? ( diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index c88de2c3..d2b2bcb1 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -148,12 +148,16 @@ export const TaskCommentsSection = ({ {reply ? ( ) : ( )} {showCollapsed && ( diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 26da5ba7..a7c5594e 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -139,6 +139,12 @@ export const MemberCard = ({ e.stopPropagation(); onOpenTask?.(); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.stopPropagation(); + e.preventDefault(); + } + }} > #{currentTask.id} {currentTask.subject.slice(0, 36)} {currentTask.subject.length > 36 ? '…' : ''} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index ef1df5f2..08c5b5d7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -462,7 +462,8 @@ export interface ElectronAPI { // Shell operations openPath: ( targetPath: string, - projectRoot?: string + projectRoot?: string, + userSelectedFromDialog?: boolean ) => Promise<{ success: boolean; error?: string }>; openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index fd041a77..54557af1 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -78,7 +78,7 @@ export interface InboxMessage { summary?: string; color?: string; messageId?: string; - source?: 'inbox' | 'lead_session'; + source?: 'inbox' | 'lead_session' | 'lead_process'; } export interface SendMessageRequest { diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index b898cf7d..2f3e0b17 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -109,6 +109,8 @@ describe('ipc teams handlers', () => { launchTeam: vi.fn(async () => ({ runId: 'run-2' })), sendMessageToTeam: vi.fn(async () => undefined), isTeamAlive: vi.fn(() => true), + relayLeadInboxMessages: vi.fn(async () => 0), + getLiveLeadProcessMessages: vi.fn(() => []), getAliveTeams: vi.fn(() => ['my-team']), stopTeam: vi.fn(() => undefined), }; @@ -207,6 +209,43 @@ describe('ipc teams handlers', () => { }); }); + it('dedups live lead replies when lead_session already has same text', async () => { + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + messages: [ + { + from: 'team-lead', + to: 'user', + text: 'Hello there', + timestamp: '2026-02-23T10:00:00.000Z', + read: true, + source: 'lead_session', + }, + ], + }); + provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ + { + from: 'team-lead', + to: 'user', + text: 'Hello there', + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_process', + messageId: 'live-1', + }, + ]); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + const result = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + data: { messages: { source?: string }[] }; + }; + expect(result.success).toBe(true); + const sources = result.data.messages.map((m) => m.source); + expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0); + expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1); + }); + describe('createTask prompt validation', () => { it('accepts valid prompt string', async () => { const handler = handlers.get(TEAM_CREATE_TASK)!; diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index a2df78ed..6240114a 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -90,11 +90,27 @@ function attachAliveRun( processKilled: false, cancelRequested: false, provisioningComplete: true, + leadRelayCapture: null, }); return { writeSpy }; } +async function waitForCapture(service: TeamProvisioningService): Promise { + const runs = (service as unknown as { runs: Map }).runs; + const run = runs.get('run-1') as any; + for (let i = 0; i < 50; i++) { + if (run?.leadRelayCapture) return run; + // Progress async awaits in relayLeadInboxMessages + await Promise.resolve(); + } + for (let i = 0; i < 50; i++) { + if (run?.leadRelayCapture) return run; + await new Promise((r) => setTimeout(r, 0)); + } + return run; +} + describe('TeamProvisioningService relayLeadInboxMessages', () => { beforeEach(() => { hoisted.files.clear(); @@ -119,13 +135,24 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ]); const { writeSpy } = attachAliveRun(service, teamName); - const relayed = await service.relayLeadInboxMessages(teamName); + + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'OK, will do.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + const relayed = await relayPromise; expect(relayed).toBe(1); expect(writeSpy).toHaveBeenCalledTimes(1); const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('"type":"user"'); expect(payload).toContain('Please assign this to Alice.'); + expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); }); it('dedups by messageId even if markRead fails', async () => { @@ -146,7 +173,15 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { hoisted.setAtomicWriteShouldFail(true); const { writeSpy } = attachAliveRun(service, teamName); - const first = await service.relayLeadInboxMessages(teamName); + const firstPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Acknowledged.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + const first = await firstPromise; const second = await service.relayLeadInboxMessages(teamName); expect(first).toBe(1); @@ -180,9 +215,18 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { processKilled: false, cancelRequested: false, provisioningComplete: true, + leadRelayCapture: null, }); - const second = await service.relayLeadInboxMessages(teamName); + const secondPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Hi.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + const second = await secondPromise; expect(second).toBe(1); expect(writeSpy).toHaveBeenCalledTimes(1); }); diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts index d0de930c..430e1e6e 100644 --- a/test/main/utils/pathValidation.test.ts +++ b/test/main/utils/pathValidation.test.ts @@ -13,6 +13,7 @@ import { isPathWithinAllowedDirectories, validateFilePath, validateOpenPath, + validateOpenPathUserSelected, } from '../../../src/main/utils/pathValidation'; describe('pathValidation', () => { @@ -299,4 +300,25 @@ describe('pathValidation', () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); }); + + describe('validateOpenPathUserSelected', () => { + it('should allow path outside project when chosen by user', () => { + const outsidePath = path.join(homeDir, 'some-other-project'); + const result = validateOpenPathUserSelected(outsidePath); + expect(result.valid).toBe(true); + expect(result.normalizedPath).toBe(path.resolve(outsidePath)); + }); + + it('should reject sensitive paths', () => { + const result = validateOpenPathUserSelected(path.join(homeDir, '.ssh', 'id_rsa')); + expect(result.valid).toBe(false); + expect(result.error).toBe('Cannot open sensitive files'); + }); + + it('should reject empty path', () => { + const result = validateOpenPathUserSelected(''); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid path'); + }); + }); });