diff --git a/src/main/index.ts b/src/main/index.ts index 2521f4c9..99a1116e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,6 +51,8 @@ import { UpdaterService, } from './services'; +import type { TeamChangeEvent } from '@shared/types'; + const logger = createLogger('App'); // Window icon path for non-mac platforms. @@ -320,12 +322,17 @@ function initializeServices(): void { httpServer = new HttpServer(); // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). - teamProvisioningService.setTeamChangeEmitter((event) => { + const teamChangeEmitter = (event: TeamChangeEvent): void => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(TEAM_CHANGE, event); } httpServer?.broadcast('team-change', event); - }); + }; + teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); + + // Start periodic health checks for registered CLI processes (every 2s). + // Dead processes get stoppedAt written to processes.json → FileWatcher picks it up. + teamDataService.startProcessHealthPolling(); // Initialize IPC handlers with registry initializeIpcHandlers( diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 5aedb665..ba20ba01 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -16,6 +16,7 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, + TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, TEAM_LIST, @@ -194,6 +195,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole); ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch); ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments); + ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess); ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); logger.info('Team handlers registered'); } @@ -231,6 +233,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE); ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH); ipcMain.removeHandler(TEAM_GET_ATTACHMENTS); + ipcMain.removeHandler(TEAM_KILL_PROCESS); ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); } @@ -1415,6 +1418,19 @@ async function handleUpdateMemberRole( }); } +async function handleKillProcess( + _event: IpcMainInvokeEvent, + teamName: unknown, + pid: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { + return { success: false, error: 'pid must be a positive integer' }; + } + return wrapTeamHandler('killProcess', () => getTeamDataService().killProcess(vTeam.value!, pid)); +} + async function handleAddTaskComment( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index fb86201a..e6f91af2 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -920,6 +920,12 @@ export class FileWatcher extends EventEmitter { return; } + if (relative === 'processes.json') { + const event: TeamChangeEvent = { type: 'process', teamName, detail: relative }; + this.emit('team-change', event); + return; + } + // Classify only the paths we care about in iteration 02. if (normalized.includes('inboxes') || relative === 'sentMessages.json') { const event: TeamChangeEvent = { diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 17227baf..e6def397 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; const TOOL_FILE_NAME = 'teamctl.js'; -const TOOL_VERSION = 5; +const TOOL_VERSION = 6; function buildTeamCtlScript(): string { const script = String.raw`#!/usr/bin/env node @@ -166,7 +166,8 @@ function getPaths(flags, teamName) { const teamDir = path.join(claudeDir, 'teams', teamName); const tasksDir = path.join(claudeDir, 'tasks', teamName); const kanbanPath = path.join(teamDir, 'kanban-state.json'); - return { claudeDir, teamDir, tasksDir, kanbanPath }; + const processesPath = path.join(teamDir, 'processes.json'); + return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath }; } function inferLeadName(paths) { @@ -421,6 +422,89 @@ function reviewRequestChanges(paths, teamName, taskId, flags) { }); } +function readProcessesSafe(filePath) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (err) { + if (err && err.code === 'EPERM') return true; + return false; + } +} + +function processRegister(paths, flags) { + const pid = Number(flags.pid); + if (!Number.isInteger(pid) || pid <= 0) die('Invalid --pid (must be > 0)'); + const label = typeof flags.label === 'string' ? flags.label.trim() : ''; + if (!label) die('Missing --label'); + + const rawPort = flags.port != null ? Number(flags.port) : undefined; + const port = rawPort != null && Number.isInteger(rawPort) && rawPort >= 1 && rawPort <= 65535 ? rawPort : undefined; + const url = typeof flags.url === 'string' && flags.url.trim() ? flags.url.trim() : undefined; + + const claudeProcessId = typeof flags['claude-process-id'] === 'string' ? flags['claude-process-id'].trim() : undefined; + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined; + const command = typeof flags.command === 'string' ? flags.command.trim() : undefined; + + const list = readProcessesSafe(paths.processesPath); + const existingIdx = list.findIndex(function (p) { return p.pid === pid; }); + + const entry = { + id: existingIdx >= 0 ? list[existingIdx].id : (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random())), + port: port, + url: url, + label: label, + pid: pid, + claudeProcessId: claudeProcessId, + registeredBy: from, + command: command, + registeredAt: existingIdx >= 0 ? list[existingIdx].registeredAt : nowIso(), + }; + + if (existingIdx >= 0) { + list[existingIdx] = entry; + } else { + list.push(entry); + } + atomicWrite(paths.processesPath, JSON.stringify(list, null, 2)); + var portStr = port ? ' port=' + String(port) : ''; + process.stdout.write('OK process registered pid=' + String(pid) + portStr + '\n'); +} + +function processUnregister(paths, flags) { + const list = readProcessesSafe(paths.processesPath); + const pid = flags.pid ? Number(flags.pid) : undefined; + const id = typeof flags.id === 'string' ? flags.id.trim() : undefined; + if (!pid && !id) die('Missing --pid or --id'); + + const idx = list.findIndex(function (p) { + if (pid) return p.pid === pid; + return p.id === id; + }); + if (idx < 0) die('Process not found'); + const removed = list.splice(idx, 1)[0]; + atomicWrite(paths.processesPath, JSON.stringify(list, null, 2)); + process.stdout.write('OK process unregistered pid=' + String(removed.pid) + '\n'); +} + +function processList(paths) { + const list = readProcessesSafe(paths.processesPath); + const result = list.map(function (p) { + return Object.assign({}, p, { alive: isProcessAlive(p.pid) }); + }); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); +} + function printHelp() { const inferred = inferTeamNameFromScriptPath(); const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : ''; @@ -439,6 +523,10 @@ function printHelp() { ' node teamctl.js review approve [--notify-owner --from "member" --note "..."] [--team ]', ' node teamctl.js review request-changes --comment "..." [--from "member"] [--team ]', ' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team ]', + ' node teamctl.js process register --pid --label