diff --git a/src/main/index.ts b/src/main/index.ts index 91b8653f..115d1fca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -155,6 +155,19 @@ function wireFileWatcherEvents(context: ServiceContext): void { mainWindow.webContents.send(TEAM_CHANGE, event); } httpServer?.broadcast('team-change', event); + + // Auto-relay direct messages to live team lead process (no UI dependency). + try { + if (!event || typeof event !== 'object') return; + const row = event as { type?: unknown; teamName?: unknown }; + if (row.type !== 'inbox') return; + if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return; + const teamName = row.teamName.trim(); + if (!teamProvisioningService.isTeamAlive(teamName)) return; + void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined); + } catch { + // ignore + } }; context.fileWatcher.on('team-change', teamChangeHandler); teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index b309b77f..af203e8d 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -21,6 +21,7 @@ import { TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, TEAM_START_TASK, + TEAM_STOP, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_TASK_STATUS, @@ -100,6 +101,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend); ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive); ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList); + ipcMain.handle(TEAM_STOP, handleStopTeam); ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); @@ -128,6 +130,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_PROCESS_SEND); ipcMain.removeHandler(TEAM_PROCESS_ALIVE); ipcMain.removeHandler(TEAM_ALIVE_LIST); + ipcMain.removeHandler(TEAM_STOP); ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); @@ -181,6 +184,13 @@ async function handleGetData( return wrapTeamHandler('getData', async () => { const data = await getTeamDataService().getTeamData(validated.value!); const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!); + if (isAlive) { + try { + await getTeamProvisioningService().relayLeadInboxMessages(validated.value!); + } catch { + // Best-effort: never fail getData due to relay issues + } + } return { ...data, isAlive }; }); } @@ -821,6 +831,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise getTeamProvisioningService().getAliveTeams()); } +async function handleStopTeam( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('stop', async () => { + getTeamProvisioningService().stopTeam(validated.value!); + }); +} + async function handleStartTask( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 15d66ea6..ece0d1e4 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -445,6 +445,9 @@ let cachedProbeResult: CachedProbeResult | null = null; export class TeamProvisioningService { private readonly runs = new Map(); private readonly activeByTeam = new Map(); + private readonly leadInboxRelayInFlight = new Map>(); + private readonly relayedLeadInboxMessageIds = new Map>(); + private readonly relayedLeadInboxFallbackKeys = new Map>(); constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -1095,6 +1098,119 @@ export class TeamProvisioningService { run.child.stdin.write(payload + '\n'); } + /** + * Relay unread inbox messages addressed to the team lead into the live lead process. + * + * Why: teammates (and the UI) write to `inboxes/.json`, but the live lead CLI + * process consumes new turns via stream-json stdin. Without relaying, the lead + * appears unresponsive to direct messages. + * + * Returns the number of messages relayed. + */ + async relayLeadInboxMessages(teamName: string): Promise { + const existing = this.leadInboxRelayInFlight.get(teamName); + if (existing) { + return existing; + } + + const work = (async (): Promise => { + const runId = this.activeByTeam.get(teamName); + if (!runId) return 0; + const run = this.runs.get(runId); + if (!run?.child || run.processKilled || run.cancelRequested) return 0; + if (!run.provisioningComplete) return 0; + + const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set(); + const relayedFallback = this.relayedLeadInboxFallbackKeys.get(teamName) ?? new Set(); + + let config: Awaited> | null = null; + try { + config = await this.configReader.getConfig(teamName); + } catch { + return 0; + } + if (!config) return 0; + + const leadName = + config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead'; + + let leadInboxMessages: Awaited> = []; + try { + leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + } catch { + return 0; + } + + const unread = leadInboxMessages + .filter((m) => { + if (m.read) return false; + if (typeof m.text !== 'string' || m.text.trim().length === 0) return false; + if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { + return !relayedIds.has(m.messageId); + } + return !relayedFallback.has(`${m.timestamp}\0${m.from}\0${m.text}`); + }) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + + if (unread.length === 0) return 0; + + const MAX_RELAY = 10; + const batch = unread.slice(0, MAX_RELAY); + + const message = [ + `You have new inbox messages addressed to you (team lead "${leadName}").`, + `Process them in order (oldest first).`, + `If action is required, delegate via task creation (teamctl.js --notify) or SendMessage, and keep responses minimal.`, + ``, + `Messages:`, + ...batch.flatMap((m, idx) => { + const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null; + return [ + `${idx + 1}) From: ${m.from || 'unknown'}`, + ` Timestamp: ${m.timestamp}`, + ...(summaryLine ? [` ${summaryLine}`] : []), + ` Text:`, + ...m.text.split('\n').map((line) => ` ${line}`), + ``, + ]; + }), + ].join('\n'); + + try { + await this.sendMessageToTeam(teamName, message); + } catch { + return 0; + } + + for (const m of batch) { + if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { + relayedIds.add(m.messageId); + } else { + relayedFallback.add(`${m.timestamp}\0${m.from}\0${m.text}`); + } + } + this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds)); + this.relayedLeadInboxFallbackKeys.set(teamName, this.trimRelayedSet(relayedFallback)); + + try { + await this.markInboxMessagesRead(teamName, leadName, batch); + } catch { + // Best-effort: relay succeeded; marking read failed. + } + + return batch.length; + })(); + + this.leadInboxRelayInFlight.set(teamName, work); + try { + return await work; + } finally { + if (this.leadInboxRelayInFlight.get(teamName) === work) { + this.leadInboxRelayInFlight.delete(teamName); + } + } + } + /** * Check if a team has a live process. */ @@ -1112,6 +1228,96 @@ export class TeamProvisioningService { return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name)); } + private async markInboxMessagesRead( + teamName: string, + member: string, + messages: { messageId?: string; timestamp: string; from: string; text: string }[] + ): Promise { + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`); + + let raw: string; + try { + raw = await fs.promises.readFile(inboxPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return; + } + if (!Array.isArray(parsed)) return; + + const ids = new Set(messages.map((m) => m.messageId).filter((id): id is string => !!id)); + const fallbackKeys = new Set( + messages.filter((m) => !m.messageId).map((m) => `${m.timestamp}\0${m.from}\0${m.text}`) + ); + + let changed = false; + for (const item of parsed) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + const msgId = typeof row.messageId === 'string' ? row.messageId : null; + const timestamp = typeof row.timestamp === 'string' ? row.timestamp : null; + const from = typeof row.from === 'string' ? row.from : null; + const text = typeof row.text === 'string' ? row.text : null; + + const matchesId = msgId ? ids.has(msgId) : false; + const matchesFallback = + !msgId && timestamp && from && text + ? fallbackKeys.has(`${timestamp}\0${from}\0${text}`) + : false; + + if (!matchesId && !matchesFallback) continue; + + if (row.read !== true) { + row.read = true; + changed = true; + } + } + + if (!changed) return; + await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2)); + } + + private trimRelayedSet(set: Set): Set { + const MAX_IDS = 2000; + if (set.size <= MAX_IDS) return set; + const next = new Set(); + const tail = Array.from(set).slice(-MAX_IDS); + for (const id of tail) next.add(id); + return next; + } + + /** + * 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}"`); + } + const run = this.runs.get(runId); + if (!run) { + this.activeByTeam.delete(teamName); + return; + } + if (run.processKilled || run.cancelRequested) { + return; + } + run.processKilled = true; + run.cancelRequested = true; + run.child?.stdin?.end(); + run.child?.kill(); + this.cleanupRun(run); + logger.info(`[${teamName}] Process stopped by user`); + } + /** * Process a parsed stream-json message from stdout. * Extracts assistant text for progress reporting and detects turn completion. @@ -1186,6 +1392,9 @@ export class TeamProvisioningService { }); run.onProgress(progress); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); + + // Pick up any direct messages that arrived before/while reconnecting. + void this.relayLeadInboxMessages(run.teamName).catch(() => undefined); return; } @@ -1224,6 +1433,9 @@ export class TeamProvisioningService { run.onProgress(progress); // NOTE: do NOT remove from activeByTeam — process stays alive logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); + + // Pick up any direct messages that arrived during provisioning. + void this.relayLeadInboxMessages(run.teamName).catch(() => undefined); } /** @@ -1236,6 +1448,9 @@ export class TeamProvisioningService { } this.stopFilesystemMonitor(run); this.activeByTeam.delete(run.teamName); + this.leadInboxRelayInFlight.delete(run.teamName); + this.relayedLeadInboxMessageIds.delete(run.teamName); + this.relayedLeadInboxFallbackKeys.delete(run.teamName); } /** diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 71914682..1a71dd20 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -235,6 +235,7 @@ export const TEAM_DELETE_TEAM = 'team:deleteTeam'; /** Get list of teams with live CLI processes */ export const TEAM_ALIVE_LIST = 'team:aliveList'; +export const TEAM_STOP = 'team:stop'; /** Create team config without provisioning CLI */ export const TEAM_CREATE_CONFIG = 'team:createConfig'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 60f89524..95821a82 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -41,6 +41,7 @@ import { TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, TEAM_START_TASK, + TEAM_STOP, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_TASK_STATUS, @@ -567,6 +568,9 @@ const electronAPI: ElectronAPI = { aliveList: async () => { return invokeIpcWithResult(TEAM_ALIVE_LIST); }, + stop: async (teamName: string) => { + return invokeIpcWithResult(TEAM_STOP, teamName); + }, createConfig: async (request: TeamCreateConfigRequest) => { return invokeIpcWithResult(TEAM_CREATE_CONFIG, request); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 6115fa22..99048216 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -685,6 +685,9 @@ export class HttpAPIClient implements ElectronAPI { aliveList: async (): Promise => { return []; }, + stop: async (): Promise => { + throw new Error('Team stop is not available in browser mode'); + }, createConfig: async (): Promise => { throw new Error('Team config creation is not available in browser mode'); }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 35cbfd92..f6f15bf7 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow'; import { ActiveTasksBlock } from './activity/ActiveTasksBlock'; import { ActivityTimeline } from './activity/ActivityTimeline'; +import { PendingRepliesBlock } from './activity/PendingRepliesBlock'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog'; @@ -67,6 +68,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>({}); const [createTaskDialog, setCreateTaskDialog] = useState({ open: false, defaultSubject: '', @@ -309,6 +311,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); + useEffect(() => { + if (!data || Object.keys(pendingRepliesByMember).length === 0) return; + const next = { ...pendingRepliesByMember }; + let changed = false; + for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { + const hasReply = data.messages.some((m) => { + if (m.from !== memberName) return false; + const ts = Date.parse(m.timestamp); + return Number.isFinite(ts) && ts > sentAtMs; + }); + if (hasReply) { + delete next[memberName]; + changed = true; + } + } + if (changed) setPendingRepliesByMember(next); + }, [data, pendingRepliesByMember]); + const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => { setCreateTaskDialog({ open: true, @@ -504,6 +524,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele { @@ -514,6 +536,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onAssignTask={(member) => { openCreateTaskDialog('', '', member.name); }} + onOpenTask={(task) => setSelectedTask(task)} /> @@ -677,6 +700,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } > + setRequestChangesTaskId(null)} onSubmit={(comment) => { if (!requestChangesTaskId) { @@ -791,7 +820,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendError={sendMessageError} lastResult={lastSendMessageResult} onSend={(member, text, summary) => { - void sendTeamMessage(teamName, { member, text, summary }); + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { member, text, summary }); + } catch { + setPendingRepliesByMember((prev) => { + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); }} onClose={() => { setSendDialogOpen(false); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 95495353..e006b076 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -14,7 +14,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { getBaseName } from '@renderer/utils/pathUtils'; -import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Trash2 } from 'lucide-react'; +import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Square, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; @@ -243,6 +243,20 @@ export const TeamListView = (): React.JSX.Element => { [teams] ); + const [stoppingTeamName, setStoppingTeamName] = useState(null); + const handleStopTeam = useCallback((teamName: string, e: React.MouseEvent) => { + e.stopPropagation(); + setStoppingTeamName(teamName); + void api.teams + .stop(teamName) + .then(() => { + setAliveTeams((prev) => prev.filter((n) => n !== teamName)); + }) + .finally(() => { + setStoppingTeamName(null); + }); + }, []); + useEffect(() => { if (!electronMode) { return; @@ -430,6 +444,24 @@ export const TeamListView = (): React.JSX.Element => {
+ {status === 'running' && ( + + + + + + {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} + + + )} + ) : ( + + {member.name} + + )} + {roleLabel ? ( + + {roleLabel} + + ) : null} + + awaiting reply + + + {since} + +
+ + ); + })} + + ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 7557bb41..37d02ab8 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -798,7 +798,7 @@ export const CreateTeamDialog = ({ {launchTeam ? (
- +