diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index f7644717..f7fecbb5 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; const TOOL_FILE_NAME = 'teamctl.js'; -const TOOL_VERSION = 4; +const TOOL_VERSION = 5; function buildTeamCtlScript(): string { const script = String.raw`#!/usr/bin/env node @@ -190,6 +190,31 @@ function setTaskStatus(paths, taskId, status) { writeTask(taskPath, task); } +function addTaskComment(paths, taskId, flags) { + var text = typeof flags.text === 'string' ? flags.text.trim() : ''; + if (!text) die('Missing --text'); + var from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'agent'; + + var ref = readTask(paths, taskId); + var task = ref.task; + var taskPath = ref.taskPath; + + var existing = Array.isArray(task.comments) ? task.comments : []; + var commentId = crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now()) + '-' + String(Math.random()); + var comment = { + id: commentId, + author: from, + text: text, + createdAt: nowIso(), + }; + task.comments = existing.concat([comment]); + writeTask(taskPath, task); + + return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner }; +} + function listTaskIds(tasksDir) { let entries = []; try { @@ -396,6 +421,7 @@ function printHelp() { ' node teamctl.js task complete [--team ]', ' node teamctl.js task start [--team ]', ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team ]', + ' node teamctl.js task comment --text "..." [--from "member"] [--team ]', ' node teamctl.js kanban set-column [--team ]', ' node teamctl.js kanban clear [--team ]', ' node teamctl.js review approve [--notify-owner --from "member" --note "..."] [--team ]', @@ -498,6 +524,25 @@ async function main() { process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n'); return; } + if (action === 'comment') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: task comment --text "..."'); + const result = addTaskComment(paths, String(id), args.flags); + const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'agent'; + // Notify task owner via inbox — but SKIP self-notification to prevent loop + if (result.owner && result.owner !== from) { + try { + sendInboxMessage(paths, teamName, { + to: result.owner, + text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''), + summary: 'Comment on #' + String(result.taskId), + from: from, + }); + } catch (e) { /* best-effort */ } + } + process.stdout.write('OK comment added to task #' + String(id) + '\n'); + return; + } die('Unknown task action: ' + String(action)); } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0c698af6..48537e72 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -319,12 +319,22 @@ export class TeamDataService { const comment = await this.taskWriter.addComment(teamName, taskId, text); try { - const tasks = await this.taskReader.getTasks(teamName); + const [tasks, toolPath] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.toolsInstaller.ensureInstalled(), + ]); const task = tasks.find((t) => t.id === taskId); if (task?.owner) { + const parts = [ + `Comment on task #${taskId} "${task.subject}":\n\n${text}`, + `\n${AGENT_BLOCK_OPEN}`, + `Reply to this comment using:`, + `node "${toolPath}" --team ${teamName} task comment ${taskId} --text "" --from ""`, + AGENT_BLOCK_CLOSE, + ]; await this.sendMessage(teamName, { member: task.owner, - text: `Comment on task #${taskId} "${task.subject}":\n\n${text}`, + text: parts.join('\n'), summary: `Comment on #${taskId}`, }); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 011d4002..fde140a8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,7 +1,10 @@ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { + encodePath, + extractBaseDir, getAutoDetectedClaudeBasePath, getClaudeBasePath, + getProjectsBasePath, getTasksBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; @@ -246,6 +249,8 @@ function buildTaskStatusProtocol(teamName: string): string { 4. If review fails and changes are needed: 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 \\"\\" Failure to follow this protocol means the task board will show incorrect status.`; } @@ -754,6 +759,39 @@ export class TeamProvisioningService { } = await this.resolveLaunchExpectedMembers(request.teamName, configRaw); const expectedMembers = expectedMemberSpecs.map((m) => m.name); + // Extract leadSessionId for session resume on reconnect. + // If a valid JSONL file exists for the previous session, we can resume it + // so the lead retains full context of prior work. + let previousSessionId: string | undefined; + try { + const configParsed = JSON.parse(configRaw) as Record; + if ( + typeof configParsed.leadSessionId === 'string' && + configParsed.leadSessionId.trim().length > 0 + ) { + const candidateId = configParsed.leadSessionId.trim(); + const projectPath = + typeof configParsed.projectPath === 'string' && configParsed.projectPath.trim().length > 0 + ? configParsed.projectPath.trim() + : request.cwd; + const projectId = encodePath(projectPath); + const baseDir = extractBaseDir(projectId); + const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`); + if (await this.pathExists(jsonlPath)) { + previousSessionId = candidateId; + logger.info( + `[${request.teamName}] Found previous session JSONL for resume: ${candidateId}` + ); + } else { + logger.info( + `[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh` + ); + } + } + } catch { + logger.debug(`[${request.teamName}] Failed to extract leadSessionId from config for resume`); + } + // IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json. // Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names. await this.normalizeTeamConfigForLaunch(request.teamName, configRaw); @@ -827,35 +865,40 @@ export class TeamProvisioningService { 'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.' ); } - try { - child = spawn( - claudePath, - [ - '--input-format', - 'stream-json', - '--output-format', - 'stream-json', - '--verbose', - '--setting-sources', - 'user,project,local', - '--disallowedTools', - 'TeamDelete,TodoWrite', - ], - { - cwd: request.cwd, - env: { - ...shellEnv, - }, - stdio: ['pipe', 'pipe', 'pipe'], - } + const launchArgs = [ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose', + '--setting-sources', + 'user,project,local', + '--disallowedTools', + 'TeamDelete,TodoWrite', + ]; + if (previousSessionId) { + launchArgs.push('--resume', previousSessionId); + logger.info( + `[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity` ); + } + + try { + child = spawn(claudePath, launchArgs, { + cwd: request.cwd, + env: { + ...shellEnv, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); } catch (error) { this.runs.delete(runId); this.activeByTeam.delete(request.teamName); throw error; } - updateProgress(run, 'spawning', 'Starting Claude CLI process for team launch', { + const resumeHint = previousSessionId ? ' (resuming previous session)' : ''; + updateProgress(run, 'spawning', `Starting Claude CLI process for team launch${resumeHint}`, { pid: child.pid ?? undefined, }); run.onProgress(run.progress); @@ -918,8 +961,11 @@ export class TeamProvisioningService { }); } - // Filesystem monitor — config already exists, start from 'waiting_members' - this.startFilesystemMonitor(run, syntheticRequest); + // For launch, skip the filesystem monitor — files (config, inboxes, tasks) + // already exist from the previous run and would trigger immediate false + // completion on the first poll. Rely on stream-json result.success instead. + updateProgress(run, 'monitoring', 'CLI running — reconnecting with teammates'); + run.onProgress(run.progress); run.timeoutHandle = setTimeout(() => { if (!run.processKilled && !run.provisioningComplete) { diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 6d9d498f..6f023bac 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -1,3 +1,4 @@ +import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useStore } from '@renderer/store'; import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { CheckCircle2, Circle, Loader2 } from 'lucide-react'; @@ -28,6 +29,7 @@ interface SidebarTaskItemProps { export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => { const openTeamTab = useStore((s) => s.openTeamTab); + const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const cfg = statusConfig[task.status] ?? statusConfig.pending; const StatusIcon = cfg.icon; const dateLabel = formatTaskDate(task.createdAt); @@ -40,6 +42,12 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme >
{task.subject} + {unreadCount > 0 && ( + + )}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e9605886..623fd180 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -524,6 +524,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
{ - void updateKanban(teamName, taskId, { op: 'remove' }); + void (async () => { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + })(); }} onStartTask={(taskId) => { void (async () => { @@ -585,9 +589,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele defaultOpen action={