diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 8ed6af1a..b0662f5e 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -63,7 +63,7 @@ export interface GraphNode { startedAt: string; finishedAt?: string; resultPreview?: string; - source: 'runtime' | 'inbox'; + source: 'runtime' | 'member_log' | 'inbox'; }; /** Recent completed tool activity for popovers and secondary UI */ recentTools?: Array<{ @@ -73,7 +73,7 @@ export interface GraphNode { startedAt: string; finishedAt: string; resultPreview?: string; - source: 'runtime' | 'inbox'; + source: 'runtime' | 'member_log' | 'inbox'; }>; // ─── Task-specific ───────────────────────────────────────────────────── diff --git a/src/main/index.ts b/src/main/index.ts index 524ae9ce..3793f78f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -106,6 +106,7 @@ import { TeamDataService, TeamLogSourceTracker, TeamMemberLogsFinder, + TeammateToolTracker, TeamProvisioningService, UpdaterService, } from './services'; @@ -784,6 +785,7 @@ function initializeServices(): void { const teamMemberLogsFinder = new TeamMemberLogsFinder(); const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); + let teammateToolTracker: TeammateToolTracker | null = null; const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); const taskBoundaryParser = new TaskBoundaryParser(); const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser); @@ -839,13 +841,27 @@ function initializeServices(): void { return getTeamControlApiBaseUrl(); }); - // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). - const teamChangeEmitter = (event: TeamChangeEvent): void => { + const forwardTeamChange = (event: TeamChangeEvent): void => { safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); }; + teammateToolTracker = new TeammateToolTracker( + teamMemberLogsFinder, + teamLogSourceTracker, + forwardTeamChange + ); + // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). + const teamChangeEmitter = (event: TeamChangeEvent): void => { + forwardTeamChange(event); + if (event.type === 'lead-activity' && event.detail === 'offline') { + teammateToolTracker?.handleTeamOffline(event.teamName); + } + }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); teamLogSourceTracker.setEmitter(teamChangeEmitter); + teamLogSourceTracker.onLogSourceChange((teamName) => { + teammateToolTracker?.handleLogSourceChange(teamName); + }); // Allow SchedulerService to push schedule events to renderer schedulerService.setChangeEmitter((event) => { @@ -874,6 +890,7 @@ function initializeServices(): void { teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, + teammateToolTracker ?? undefined, { rewire: rewireContextEvents, full: onContextSwitched, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 9f57c9dc..9d2934ca 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -100,6 +100,7 @@ import type { SshConnectionManager, TeamDataService, TeamMemberLogsFinder, + TeammateToolTracker, TeamProvisioningService, UpdaterService, } from '../services'; @@ -127,6 +128,7 @@ export function initializeIpcHandlers( teamProvisioningService: TeamProvisioningService, teamMemberLogsFinder: TeamMemberLogsFinder, memberStatsComputer: MemberStatsComputer, + teammateToolTracker: TeammateToolTracker | undefined, contextCallbacks: { rewire: (context: ServiceContext) => void; full: (context: ServiceContext) => void; @@ -167,7 +169,8 @@ export function initializeIpcHandlers( teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, - teamBackupService + teamBackupService, + teammateToolTracker ); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index cabe55b4..773d3bfb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -48,6 +48,7 @@ import { TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, + TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -110,6 +111,7 @@ import { import type { MemberStatsComputer, + TeammateToolTracker, TeamDataService, TeamMemberLogsFinder, TeamProvisioningService, @@ -272,6 +274,7 @@ let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; +let teammateToolTracker: TeammateToolTracker | null = null; const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); @@ -300,13 +303,15 @@ export function initializeTeamHandlers( provisioningService: TeamProvisioningService, logsFinder?: TeamMemberLogsFinder, statsComputer?: MemberStatsComputer, - backupService?: TeamBackupService + backupService?: TeamBackupService, + toolTracker?: TeammateToolTracker ): void { teamDataService = service; teamProvisioningService = provisioningService; teamMemberLogsFinder = logsFinder ?? null; memberStatsComputer = statsComputer ?? null; teamBackupService = backupService ?? null; + teammateToolTracker = toolTracker ?? null; } export function registerTeamHandlers(ipcMain: IpcMain): void { @@ -314,6 +319,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_DATA, handleGetData); ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); + ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); @@ -378,6 +384,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_DATA); ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); + ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); @@ -450,6 +457,13 @@ function getTeamProvisioningService(): TeamProvisioningService { return teamProvisioningService; } +function getTeammateToolTracker(): TeammateToolTracker { + if (!teammateToolTracker) { + throw new Error('Teammate tool tracker is not initialized'); + } + return teammateToolTracker; +} + async function wrapTeamHandler( operation: string, handler: () => Promise @@ -655,6 +669,24 @@ async function handleSetChangePresenceTracking( }); } +async function handleSetToolActivityTracking( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setToolActivityTracking', async () => { + await getTeammateToolTracker().setTracking(validated.value!, enabled); + }); +} + async function handleDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index f70070bf..46e4a2f7 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -197,7 +197,7 @@ export class TeamDataService { if (enabled) { void this.teamLogSourceTracker - .ensureTracking(teamName) + .setTracking(teamName, 'change_presence', true) .catch((error) => logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`) ); @@ -205,7 +205,7 @@ export class TeamDataService { } void this.teamLogSourceTracker - .stopTracking(teamName) + .setTracking(teamName, 'change_presence', false) .catch((error) => logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`) ); diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 64ad4880..d1c24682 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -20,6 +20,8 @@ interface TeamLogSourceSnapshot { logSourceGeneration: string | null; } +export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity'; + interface TrackingState { watcher: FSWatcher | null; projectDir: string | null; @@ -29,13 +31,14 @@ interface TrackingState { recomputePromise: Promise | null; recomputeVersion: number | null; snapshot: TeamLogSourceSnapshot; - desiredTracking: boolean; + consumers: Set; lifecycleVersion: number; } export class TeamLogSourceTracker { private readonly stateByTeam = new Map(); private emitter: ((event: TeamChangeEvent) => void) | null = null; + private readonly changeListeners = new Set<(teamName: string) => void>(); constructor(private readonly logsFinder: TeamMemberLogsFinder) {} @@ -43,22 +46,46 @@ export class TeamLogSourceTracker { this.emitter = emitter; } + onLogSourceChange(listener: (teamName: string) => void): () => void { + this.changeListeners.add(listener); + return () => { + this.changeListeners.delete(listener); + }; + } + getSnapshot(teamName: string): TeamLogSourceSnapshot | null { const state = this.stateByTeam.get(teamName); return state ? { ...state.snapshot } : null; } + async setTracking( + teamName: string, + consumer: TeamLogSourceTrackingConsumer, + enabled: boolean + ): Promise { + return enabled + ? this.enableTracking(teamName, consumer) + : this.disableTracking(teamName, consumer); + } + async ensureTracking(teamName: string): Promise { + return this.enableTracking(teamName, 'change_presence'); + } + + private async enableTracking( + teamName: string, + consumer: TeamLogSourceTrackingConsumer + ): Promise { const state = this.getOrCreateState(teamName); - if (!state.desiredTracking) { - state.desiredTracking = true; + if (!state.consumers.has(consumer)) { + state.consumers.add(consumer); state.lifecycleVersion += 1; } if ( state.initializePromise && state.initializeVersion === state.lifecycleVersion && - state.desiredTracking + state.consumers.size > 0 ) { return state.initializePromise; } @@ -101,7 +128,7 @@ export class TeamLogSourceTracker { recomputePromise: null, recomputeVersion: null, snapshot: { projectFingerprint: null, logSourceGeneration: null }, - desiredTracking: false, + consumers: new Set(), lifecycleVersion: 0, }; this.stateByTeam.set(teamName, created); @@ -109,16 +136,27 @@ export class TeamLogSourceTracker { } async stopTracking(teamName: string): Promise { + await this.disableTracking(teamName, 'change_presence'); + } + + private async disableTracking( + teamName: string, + consumer: TeamLogSourceTrackingConsumer + ): Promise { const state = this.stateByTeam.get(teamName); if (!state) { - return; + return { projectFingerprint: null, logSourceGeneration: null }; } - if (state.desiredTracking) { - state.desiredTracking = false; + if (state.consumers.has(consumer)) { + state.consumers.delete(consumer); state.lifecycleVersion += 1; } + if (state.consumers.size > 0) { + return { ...state.snapshot }; + } + if (state.refreshTimer) { clearTimeout(state.refreshTimer); state.refreshTimer = null; @@ -131,11 +169,12 @@ export class TeamLogSourceTracker { state.projectDir = null; state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + return { ...state.snapshot }; } private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { const state = this.stateByTeam.get(teamName); - return !!state && state.desiredTracking && state.lifecycleVersion === expectedVersion; + return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion; } private async initializeTeam( @@ -167,10 +206,7 @@ export class TeamLogSourceTracker { state.snapshot.logSourceGeneration && previousGeneration !== state.snapshot.logSourceGeneration ) { - this.emitter?.({ - type: 'log-source-change', - teamName, - }); + this.emitLogSourceChange(teamName); } return snapshot; } @@ -181,7 +217,7 @@ export class TeamLogSourceTracker { expectedVersion: number ): Promise { const state = this.stateByTeam.get(teamName); - if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) { + if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) { return; } if (state.projectDir === projectDir && state.watcher) { @@ -216,7 +252,7 @@ export class TeamLogSourceTracker { const scheduleRecompute = (): void => { const current = this.stateByTeam.get(teamName); - if (!current?.desiredTracking) { + if (!current || current.consumers.size === 0) { return; } if (current.refreshTimer) { @@ -240,13 +276,13 @@ export class TeamLogSourceTracker { private async recompute(teamName: string): Promise { const state = this.getOrCreateState(teamName); - if (!state.desiredTracking) { + if (state.consumers.size === 0) { return state.snapshot; } if ( state.recomputePromise && state.recomputeVersion === state.lifecycleVersion && - state.desiredTracking + state.consumers.size > 0 ) { return state.recomputePromise; } @@ -278,10 +314,7 @@ export class TeamLogSourceTracker { state.snapshot.logSourceGeneration && previousGeneration !== state.snapshot.logSourceGeneration ) { - this.emitter?.({ - type: 'log-source-change', - teamName, - }); + this.emitLogSourceChange(teamName); } return state.snapshot; @@ -298,6 +331,20 @@ export class TeamLogSourceTracker { return recomputePromise; } + private emitLogSourceChange(teamName: string): void { + this.emitter?.({ + type: 'log-source-change', + teamName, + }); + for (const listener of this.changeListeners) { + try { + listener(teamName); + } catch (error) { + logger.warn(`Log-source listener failed for ${teamName}: ${String(error)}`); + } + } + } + private async computeSnapshot(context: { projectDir: string; projectPath?: string; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index ea15c5fb..f4a70432 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -25,6 +25,7 @@ const ATTRIBUTION_SCAN_LINES = 50; /** Grace before task creation — logs cannot reference a task before it exists. */ const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; const FILE_MENTIONS_CACHE_MAX = 10_000; +const ATTRIBUTION_CACHE_MAX = 5_000; /** Max concurrent file reads during parallel scan phases. */ const SCAN_CONCURRENCY = 15; @@ -87,6 +88,7 @@ function trimTrailingSlashes(value: string): string { export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); + private readonly attributionCache = new Map(); private readonly discoveryCache = new Map< string, { @@ -660,7 +662,17 @@ export class TeamMemberLogsFinder { const filePath = path.join(subagentsDir, file); // Quick attribution check — only Phase 1 (no full-file streaming) - const attribution = await this.attributeSubagent(filePath, knownMembers); + let mtimeMs = 0; + try { + mtimeMs = (await fs.stat(filePath)).mtimeMs; + } catch { + continue; + } + const attribution = await this.getCachedSubagentAttribution( + filePath, + knownMembers, + mtimeMs + ); if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { paths.push(filePath); } @@ -670,6 +682,50 @@ export class TeamMemberLogsFinder { return paths; } + async listAttributedSubagentFiles( + teamName: string + ): Promise> { + const discovery = await this.discoverProjectSessions(teamName); + if (!discovery) return []; + + const { projectDir, sessionIds, knownMembers } = discovery; + const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); + const results: Array<{ + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + }> = []; + + const settled = await Promise.all( + candidates.map(async (candidate) => { + try { + const stat = await fs.stat(candidate.filePath); + const attribution = await this.getCachedSubagentAttribution( + candidate.filePath, + knownMembers, + stat.mtimeMs + ); + if (!attribution) return null; + return { + memberName: attribution.detectedMember, + sessionId: candidate.sessionId, + filePath: candidate.filePath, + mtimeMs: stat.mtimeMs, + }; + } catch { + return null; + } + }) + ); + + for (const item of settled) { + if (item) results.push(item); + } + + return results; + } + /** * Fast marker probe for task-related logs. * Prefer structured MCP/TaskUpdate markers for modern sessions. @@ -1150,6 +1206,24 @@ export class TeamMemberLogsFinder { } } + private async getCachedSubagentAttribution( + filePath: string, + knownMembers: Set, + mtimeMs: number + ): Promise { + const cacheKey = `${filePath}:${mtimeMs}`; + if (this.attributionCache.has(cacheKey)) { + return this.attributionCache.get(cacheKey) ?? null; + } + const attribution = await this.attributeSubagent(filePath, knownMembers); + this.attributionCache.set(cacheKey, attribution); + if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) { + const oldestKey = this.attributionCache.keys().next().value; + if (oldestKey) this.attributionCache.delete(oldestKey); + } + return attribution; + } + private async parseSubagentSummary( filePath: string, projectId: string, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e707c71f..53da2cfb 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4046,11 +4046,7 @@ export class TeamProvisioningService { if (typeof msg.text !== 'string') continue; const perm = parsePermissionRequest(msg.text); if (!perm) continue; - if (run.processedPermissionRequestIds.has(perm.requestId)) continue; - run.processedPermissionRequestIds.add(perm.requestId); - logger.warn( - `[${run.teamName}] [PERM-TRACE] Intercepted permission_request from inbox scan (read=${String(msg.read)}): agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}` - ); + // Dedup is handled inside handleTeammatePermissionRequest via processedPermissionRequestIds this.handleTeammatePermissionRequest(run, perm, msg.timestamp); } } catch { @@ -4193,32 +4189,19 @@ export class TeamProvisioningService { ); const deferredIds = new Set(deferredByAge.map((m) => m.messageId)); - // Category 4: teammate permission requests — intercept and convert to tool approvals. - // Don't relay these to the lead agent (it can't handle them). - // NOTE: We intentionally do NOT exclude nativeMatchedMessageIds here — even if - // Claude Code runtime natively delivered the message to the lead, we still need - // to intercept permission_request and show the ToolApprovalSheet for the user. - const permissionRequestMsgs = unread.filter( - (m) => - !permanentlyIgnoredIds.has(m.messageId) && - !deferredIds.has(m.messageId) && - parsePermissionRequest(m.text) !== null + // Category 4: teammate permission requests — filter from actionable so they're + // NOT relayed to the lead. The actual interception + ToolApprovalRequest emission + // is handled by the early scan above (which checks processedPermissionRequestIds). + const permissionRequestIds = new Set( + unread + .filter( + (m) => + !permanentlyIgnoredIds.has(m.messageId) && + !deferredIds.has(m.messageId) && + parsePermissionRequest(m.text) !== null + ) + .map((m) => m.messageId) ); - const permissionRequestIds = new Set(permissionRequestMsgs.map((m) => m.messageId)); - if (permissionRequestMsgs.length > 0) { - logger.warn( - `[${run.teamName}] [PERM-TRACE] relay intercepted ${permissionRequestMsgs.length} permission_request(s) from inbox` - ); - for (const msg of permissionRequestMsgs) { - const perm = parsePermissionRequest(msg.text)!; - this.handleTeammatePermissionRequest(run, perm, msg.timestamp); - } - try { - await this.markInboxMessagesRead(teamName, leadName, permissionRequestMsgs); - } catch { - // best-effort - } - } // Actionable: everything not in any category. const actionableUnread = unread.filter( @@ -5777,13 +5760,11 @@ export class TeamProvisioningService { perm: ParsedPermissionRequest, messageTimestamp: string ): void { - // Skip if already tracked (idempotency — relay can be called multiple times) - if (run.pendingApprovals.has(perm.requestId)) { - logger.warn( - `[${run.teamName}] [PERM-TRACE] Duplicate permission_request skipped: ${perm.requestId}` - ); - return; - } + // Skip if already tracked (idempotency — multiple paths can trigger this: + // early inbox scan, stdout parsing, native message blocks, relay Category 4) + if (run.processedPermissionRequestIds.has(perm.requestId)) return; + if (run.pendingApprovals.has(perm.requestId)) return; + run.processedPermissionRequestIds.add(perm.requestId); logger.warn( `[${run.teamName}] [PERM-TRACE] handleTeammatePermissionRequest: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}` diff --git a/src/main/services/team/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts new file mode 100644 index 00000000..c8f02f06 --- /dev/null +++ b/src/main/services/team/TeammateToolTracker.ts @@ -0,0 +1,520 @@ +import { extractToolPreview, extractToolResultPreview } from '@shared/utils/toolSummary'; +import * as fs from 'fs/promises'; + +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { ActiveToolCall, TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; + +const MAX_SEEN_FINISHED_IDS = 512; + +interface FileState { + memberName: string; + sessionId: string; + lastSize: number; + lastMtimeMs: number; + lineCarry: string; + activeTools: Map; + seenFinished: Set; +} + +interface TeamState { + enabled: boolean; + epoch: number; + filesByPath: Map; + refreshInFlight: boolean; + refreshQueued: boolean; +} + +interface AttributedSubagentFile { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; +} + +interface ParsedFileSnapshot { + lastSize: number; + lastMtimeMs: number; + lineCarry: string; + activeTools: Map; + seenFinished: Set; +} + +export class TeammateToolTracker { + private readonly stateByTeam = new Map(); + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly logSourceTracker: TeamLogSourceTracker, + private readonly emitTeamChange: (event: TeamChangeEvent) => void + ) {} + + async setTracking(teamName: string, enabled: boolean): Promise { + if (enabled) { + await this.enableTracking(teamName); + return; + } + await this.disableTracking(teamName); + } + + async dispose(): Promise { + await Promise.all( + [...this.stateByTeam.keys()].map((teamName) => this.disableTracking(teamName)) + ); + } + + handleLogSourceChange(teamName: string): void { + const state = this.stateByTeam.get(teamName); + if (!state?.enabled) return; + void this.refreshTeam(teamName); + } + + handleTeamOffline(teamName: string): void { + const state = this.stateByTeam.get(teamName); + if (!state?.enabled) return; + state.epoch += 1; + this.resetAllTrackedTools(teamName, state.filesByPath); + state.filesByPath.clear(); + state.refreshQueued = false; + } + + private getOrCreateState(teamName: string): TeamState { + const existing = this.stateByTeam.get(teamName); + if (existing) return existing; + const created: TeamState = { + enabled: false, + epoch: 0, + filesByPath: new Map(), + refreshInFlight: false, + refreshQueued: false, + }; + this.stateByTeam.set(teamName, created); + return created; + } + + private async enableTracking(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (state.enabled) { + await this.refreshTeam(teamName); + return; + } + state.enabled = true; + state.epoch += 1; + state.filesByPath.clear(); + state.refreshQueued = false; + await this.logSourceTracker.setTracking(teamName, 'tool_activity', true); + await this.refreshTeam(teamName); + } + + private async disableTracking(teamName: string): Promise { + const state = this.stateByTeam.get(teamName); + if (!state) { + await this.logSourceTracker.setTracking(teamName, 'tool_activity', false); + return; + } + state.enabled = false; + state.epoch += 1; + this.resetAllTrackedTools(teamName, state.filesByPath); + state.filesByPath.clear(); + state.refreshQueued = false; + await this.logSourceTracker.setTracking(teamName, 'tool_activity', false); + } + + private async refreshTeam(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (!state.enabled) return; + + if (state.refreshInFlight) { + state.refreshQueued = true; + return; + } + + state.refreshInFlight = true; + try { + do { + state.refreshQueued = false; + const expectedEpoch = state.epoch; + await this.performRefresh(teamName, expectedEpoch); + } while (state.enabled && state.refreshQueued); + } finally { + state.refreshInFlight = false; + } + } + + private async performRefresh(teamName: string, expectedEpoch: number): Promise { + const state = this.stateByTeam.get(teamName); + if (!state?.enabled || state.epoch !== expectedEpoch) return; + + const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); + const currentState = this.stateByTeam.get(teamName); + if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; + + const fileByPath = new Map(attributedFiles.map((file) => [file.filePath, file])); + + for (const [filePath, fileState] of currentState.filesByPath.entries()) { + if (fileByPath.has(filePath)) continue; + this.emitTargetedReset(teamName, fileState.memberName, [...fileState.activeTools.keys()]); + currentState.filesByPath.delete(filePath); + } + + for (const file of attributedFiles) { + const liveState = this.stateByTeam.get(teamName); + if (!liveState?.enabled || liveState.epoch !== expectedEpoch) return; + + const existing = liveState.filesByPath.get(file.filePath); + let stat; + try { + stat = await fs.stat(file.filePath); + } catch { + if (existing) { + this.emitTargetedReset(teamName, existing.memberName, [...existing.activeTools.keys()]); + liveState.filesByPath.delete(file.filePath); + } + continue; + } + if (!stat.isFile()) continue; + + const attributionChanged = + existing && + (existing.memberName !== file.memberName || existing.sessionId !== file.sessionId); + + if (!existing || attributionChanged) { + const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + if (existing) { + this.emitTargetedReset(teamName, existing.memberName, [...existing.activeTools.keys()]); + } + latestState.filesByPath.set( + file.filePath, + this.applyParsedSnapshot( + teamName, + file, + attributionChanged ? null : (existing ?? null), + parsed + ) + ); + continue; + } + + if (stat.size < existing.lastSize) { + const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + latestState.filesByPath.set( + file.filePath, + this.applyParsedSnapshot(teamName, file, existing, parsed) + ); + continue; + } + + if (stat.size === existing.lastSize && stat.mtimeMs === existing.lastMtimeMs) { + continue; + } + + if (stat.size === existing.lastSize) { + const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + latestState.filesByPath.set( + file.filePath, + this.applyParsedSnapshot(teamName, file, existing, parsed) + ); + continue; + } + + const nextState = await this.applyDelta(teamName, file, existing, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + latestState.filesByPath.set(file.filePath, nextState); + } + } + + private async parseFileSnapshot( + file: AttributedSubagentFile, + size: number, + mtimeMs: number + ): Promise { + const content = await fs.readFile(file.filePath, 'utf8').catch(() => ''); + const { lines, carry } = splitJsonLines(content); + const activeTools = new Map(); + const seenFinished = new Set(); + + for (const line of lines) { + this.consumeJsonLine(line, file, activeTools, seenFinished); + } + + return { + lastSize: size, + lastMtimeMs: mtimeMs, + lineCarry: carry, + activeTools, + seenFinished, + }; + } + + private applyParsedSnapshot( + teamName: string, + file: AttributedSubagentFile, + existing: FileState | null, + parsed: ParsedFileSnapshot + ): FileState { + const previousActive = existing?.activeTools ?? new Map(); + const nextActiveIds = new Set(parsed.activeTools.keys()); + const removedIds = [...previousActive.keys()].filter( + (toolUseId) => !nextActiveIds.has(toolUseId) + ); + if (removedIds.length > 0 && existing) { + this.emitTargetedReset(teamName, existing.memberName, removedIds); + } + + for (const [toolUseId, activity] of parsed.activeTools.entries()) { + if (previousActive.has(toolUseId)) continue; + this.emitStart(teamName, activity); + } + + return { + memberName: file.memberName, + sessionId: file.sessionId, + lastSize: parsed.lastSize, + lastMtimeMs: parsed.lastMtimeMs, + lineCarry: parsed.lineCarry, + activeTools: parsed.activeTools, + seenFinished: parsed.seenFinished, + }; + } + + private async applyDelta( + teamName: string, + file: AttributedSubagentFile, + fileState: FileState, + nextSize: number, + nextMtimeMs: number + ): Promise { + const nextActiveTools = new Map(fileState.activeTools); + const nextSeenFinished = new Set(fileState.seenFinished); + const appendedChunk = await readAppendedChunk(file.filePath, fileState.lastSize, nextSize); + const { lines, carry } = splitJsonLines(fileState.lineCarry + appendedChunk); + + for (const line of lines) { + this.consumeJsonLine(line, file, nextActiveTools, nextSeenFinished, { + emitStart: (activity) => this.emitStart(teamName, activity), + emitFinish: (activity, result) => this.emitFinish(teamName, activity, result), + }); + } + + return { + memberName: fileState.memberName, + sessionId: fileState.sessionId, + lastSize: nextSize, + lastMtimeMs: nextMtimeMs, + lineCarry: carry, + activeTools: nextActiveTools, + seenFinished: nextSeenFinished, + }; + } + + private consumeJsonLine( + line: string, + file: AttributedSubagentFile, + activeTools: Map, + seenFinished: Set, + emitters?: { + emitStart?: (activity: ActiveToolCall) => void; + emitFinish?: (activity: ActiveToolCall, result: FinishPayload) => void; + } + ): void { + let entry: Record; + try { + entry = JSON.parse(line) as Record; + } catch { + return; + } + + const timestamp = extractEntryTimestamp(entry) ?? new Date().toISOString(); + const content = extractEntryContent(entry); + if (!content) return; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const typedBlock = block as Record; + if (typedBlock.type === 'tool_use') { + const rawId = typeof typedBlock.id === 'string' ? typedBlock.id.trim() : ''; + if (!rawId) continue; + const toolUseId = buildCompositeToolUseId(file.sessionId, rawId); + if (activeTools.has(toolUseId) || seenFinished.has(toolUseId)) continue; + const toolName = typeof typedBlock.name === 'string' ? typedBlock.name : 'Tool'; + const input = + typedBlock.input && typeof typedBlock.input === 'object' + ? (typedBlock.input as Record) + : {}; + const activity: ActiveToolCall = { + memberName: file.memberName, + toolUseId, + toolName, + preview: extractToolPreview(toolName, input), + startedAt: timestamp, + source: 'member_log', + state: 'running', + }; + activeTools.set(toolUseId, activity); + emitters?.emitStart?.(activity); + continue; + } + + if (typedBlock.type !== 'tool_result' || typeof typedBlock.tool_use_id !== 'string') continue; + const toolUseId = buildCompositeToolUseId(file.sessionId, typedBlock.tool_use_id); + const active = activeTools.get(toolUseId); + if (active) { + activeTools.delete(toolUseId); + pushBoundedSetValue(seenFinished, toolUseId, MAX_SEEN_FINISHED_IDS); + emitters?.emitFinish?.(active, { + finishedAt: timestamp, + resultPreview: extractToolResultPreview(typedBlock.content), + isError: typedBlock.is_error === true, + }); + continue; + } + + pushBoundedSetValue(seenFinished, toolUseId, MAX_SEEN_FINISHED_IDS); + } + } + + private emitStart(teamName: string, activity: ActiveToolCall): void { + const payload: ToolActivityEventPayload = { + action: 'start', + activity: { + memberName: activity.memberName, + toolUseId: activity.toolUseId, + toolName: activity.toolName, + preview: activity.preview, + startedAt: activity.startedAt, + source: activity.source, + }, + }; + this.emitTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify(payload), + }); + } + + private emitFinish(teamName: string, activity: ActiveToolCall, result: FinishPayload): void { + const payload: ToolActivityEventPayload = { + action: 'finish', + memberName: activity.memberName, + toolUseId: activity.toolUseId, + finishedAt: result.finishedAt, + resultPreview: result.resultPreview, + isError: result.isError, + }; + this.emitTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify(payload), + }); + } + + private emitTargetedReset(teamName: string, memberName: string, toolUseIds: string[]): void { + if (toolUseIds.length === 0) return; + const payload: ToolActivityEventPayload = { + action: 'reset', + memberName, + toolUseIds, + }; + this.emitTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify(payload), + }); + } + + private resetAllTrackedTools(teamName: string, filesByPath: Map): void { + for (const fileState of filesByPath.values()) { + this.emitTargetedReset(teamName, fileState.memberName, [...fileState.activeTools.keys()]); + } + } +} + +interface FinishPayload { + finishedAt: string; + resultPreview?: string; + isError?: boolean; +} + +function buildCompositeToolUseId(sessionId: string, rawToolUseId: string): string { + return `member_log:${sessionId}:${rawToolUseId}`; +} + +function extractEntryContent(entry: Record): Record[] | null { + if (Array.isArray(entry.content)) return entry.content as Record[]; + const message = entry.message; + if ( + message && + typeof message === 'object' && + Array.isArray((message as { content?: unknown[] }).content) + ) { + return (message as { content: Record[] }).content; + } + return null; +} + +function extractEntryTimestamp(entry: Record): string | null { + if (typeof entry.timestamp === 'string' && entry.timestamp.trim().length > 0) { + return entry.timestamp; + } + const message = entry.message; + if ( + message && + typeof message === 'object' && + typeof (message as { timestamp?: unknown }).timestamp === 'string' + ) { + return (message as { timestamp: string }).timestamp; + } + return null; +} + +function splitJsonLines(text: string): { lines: string[]; carry: string } { + const normalized = text.replace(/\r\n/g, '\n'); + const rawParts = normalized.split('\n'); + let carry = rawParts.pop() ?? ''; + const lines = rawParts.map((part) => part.trim()).filter((part) => part.length > 0); + const trimmedCarry = carry.trim(); + if (trimmedCarry.length > 0) { + try { + JSON.parse(trimmedCarry); + lines.push(trimmedCarry); + carry = ''; + } catch { + carry = trimmedCarry; + } + } else { + carry = ''; + } + return { lines, carry }; +} + +async function readAppendedChunk(filePath: string, start: number, end: number): Promise { + if (end <= start) return ''; + const length = end - start; + const handle = await fs.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, start); + return buffer.toString('utf8'); + } finally { + await handle.close().catch(() => undefined); + } +} + +function pushBoundedSetValue(set: Set, value: string, limit: number): void { + if (set.has(value)) { + set.delete(value); + } + set.add(value); + while (set.size > limit) { + const oldest = set.values().next().value; + if (!oldest) break; + set.delete(oldest); + } +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 75a6e638..afb09e5e 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -20,6 +20,7 @@ export { TeamLogSourceTracker } from './TeamLogSourceTracker'; export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMembersMetaStore } from './TeamMembersMetaStore'; +export { TeammateToolTracker } from './TeammateToolTracker'; export { TeamProvisioningService } from './TeamProvisioningService'; export { TeamSentMessagesStore } from './TeamSentMessagesStore'; export { TeamTaskReader } from './TeamTaskReader'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 30ac6dee..8d523c6f 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -216,6 +216,9 @@ export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence'; /** Enable or disable task change presence tracking for a visible team tab */ export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking'; +/** Enable or disable live teammate tool activity tracking for a visible team tab */ +export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking'; + /** Get buffered Claude CLI logs (paged, newest-first) */ export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 13b70812..85eb3fdf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -150,6 +150,7 @@ import { TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, + TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -812,6 +813,9 @@ const electronAPI: ElectronAPI = { setChangePresenceTracking: async (teamName: string, enabled: boolean) => { return invokeIpcWithResult(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled); }, + setToolActivityTracking: async (teamName: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled); + }, getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 20a6568a..419c4dde 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -676,6 +676,9 @@ export class HttpAPIClient implements ElectronAPI { setChangePresenceTracking: async (): Promise => { // Not available in browser mode — no-op. }, + setToolActivityTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, getClaudeLogs: async ( _teamName: string, _query?: TeamClaudeLogsQuery diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 11d2ddce..ba32f6f1 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -54,6 +54,7 @@ import { Command, ListPlus, Maximize2, + MoveRight, RefreshCw, Reply, X, @@ -835,9 +836,7 @@ export const ActivityItem = memo( {/* Recipient — arrow + avatar + badge */} {message.to && message.to !== message.from ? ( <> - - → - + {crossTeamTarget ? ( ) : null} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index a5eb1ab1..72632133 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -277,6 +277,21 @@ export function initializeNotificationListeners(): () => void { delete nextTeamState[memberName]; return nextTeamState; }; + const removeMemberToolEntries = ( + teamState: Record> | undefined, + memberName: string, + toolUseIds: readonly string[] + ): Record> => { + if (!teamState?.[memberName] || toolUseIds.length === 0) return teamState ?? {}; + let nextTeamState = teamState ?? {}; + let changed = false; + for (const toolUseId of toolUseIds) { + if (!nextTeamState[memberName]?.[toolUseId]) continue; + nextTeamState = removeMemberToolEntry(nextTeamState, memberName, toolUseId); + changed = true; + } + return changed ? nextTeamState : (teamState ?? {}); + }; const getBaseProjectId = (projectId: string | null | undefined): string | null => { if (!projectId) return null; const separatorIndex = projectId.indexOf('::'); @@ -461,6 +476,19 @@ export function initializeNotificationListeners(): () => void { return new Set([selectedTeamName]); }; + const getTrackedToolActivityTeams = (): Set => { + const { paneLayout } = useStore.getState(); + const tracked = new Set(); + for (const pane of paneLayout.panes) { + if (!pane.activeTabId) continue; + const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId); + if (activeTab?.type === 'team' && activeTab.teamName) { + tracked.add(activeTab.teamName); + } + } + return tracked; + }; + if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) { let trackedTeamNames = new Set(); const syncVisibleTeamTracking = (): void => { @@ -503,6 +531,44 @@ export function initializeNotificationListeners(): () => void { }); } + if (api.teams?.setToolActivityTracking) { + let trackedTeamNames = new Set(); + const syncVisibleTeamTracking = (): void => { + const nextTrackedTeamNames = getTrackedToolActivityTeams(); + + for (const teamName of nextTrackedTeamNames) { + if (!trackedTeamNames.has(teamName)) { + void api.teams.setToolActivityTracking(teamName, true).catch(() => undefined); + } + } + + for (const teamName of trackedTeamNames) { + if (!nextTrackedTeamNames.has(teamName)) { + void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined); + } + } + + trackedTeamNames = nextTrackedTeamNames; + }; + + syncVisibleTeamTracking(); + + const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => { + if (state.paneLayout === prevState.paneLayout) { + return; + } + syncVisibleTeamTracking(); + }); + + cleanupFns.push(() => { + unsubscribeVisibleTeamTracking(); + for (const teamName of trackedTeamNames) { + void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined); + } + trackedTeamNames.clear(); + }); + } + // Listen for task-list file changes to refresh currently viewed session metadata if (api.onTodoChange) { const cleanup = api.onTodoChange((event) => { @@ -801,6 +867,10 @@ export function initializeNotificationListeners(): () => void { } else if (payload.action === 'reset') { if (payload.memberName) { const memberName = payload.memberName; + const toolUseIds = + Array.isArray(payload.toolUseIds) && payload.toolUseIds.length > 0 + ? payload.toolUseIds + : null; useStore.setState((prev) => { if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) { return {}; @@ -808,10 +878,13 @@ export function initializeNotificationListeners(): () => void { return { activeToolsByTeam: { ...prev.activeToolsByTeam, - [event.teamName]: removeMemberToolGroup( - prev.activeToolsByTeam[event.teamName], - memberName - ), + [event.teamName]: toolUseIds + ? removeMemberToolEntries( + prev.activeToolsByTeam[event.teamName], + memberName, + toolUseIds + ) + : removeMemberToolGroup(prev.activeToolsByTeam[event.teamName], memberName), }, }; }); diff --git a/src/renderer/utils/markdownPlugins.ts b/src/renderer/utils/markdownPlugins.ts index 1652702b..0fccb94c 100644 --- a/src/renderer/utils/markdownPlugins.ts +++ b/src/renderer/utils/markdownPlugins.ts @@ -39,6 +39,11 @@ const sanitizeSchema: SanitizeSchema = { // Allow title on abbr (for tooltip definitions) abbr: [...(defaultSchema.attributes?.abbr ?? []), 'title'], }, + protocols: { + ...defaultSchema.protocols, + // Allow internal-only protocols used for mention badges, team badges, and task tooltips + href: [...(defaultSchema.protocols?.href ?? []), 'mention', 'team', 'task'], + }, }; /** Full plugin chain: raw HTML → sanitize → syntax highlighting */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e0b9c277..43f94377 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -419,6 +419,7 @@ export interface TeamsAPI { getData: (teamName: string) => Promise; getTaskChangePresence: (teamName: string) => Promise>; setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; + setToolActivityTracking: (teamName: string, enabled: boolean) => Promise; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise; deleteTeam: (teamName: string) => Promise; restoreTeam: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b42c89ff..59f236f9 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -308,7 +308,7 @@ export interface ToolCallMeta { toolUseId?: string; } -export type ToolActivitySource = 'runtime' | 'inbox'; +export type ToolActivitySource = 'runtime' | 'member_log' | 'inbox'; export type ToolActivityState = 'running' | 'complete' | 'error'; /** Live or recently finished tool activity for one team member. */ @@ -337,6 +337,7 @@ export interface ToolActivityEventPayload { }; memberName?: string; toolUseId?: string; + toolUseIds?: string[]; finishedAt?: string; resultPreview?: string; isError?: boolean; diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index b2b490b2..c9f20810 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -93,6 +93,7 @@ export function extractToolPreview( case 'Glob': return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined; case 'Agent': + case 'Task': case 'TaskCreate': return typeof input.prompt === 'string' ? input.prompt @@ -116,3 +117,34 @@ export function extractToolPreview( } } } + +function flattenToolResultContent(content: unknown): string[] { + if (typeof content === 'string') { + return [content]; + } + + if (!Array.isArray(content)) { + return []; + } + + const parts: string[] = []; + for (const item of content) { + if (!item || typeof item !== 'object') continue; + const block = item as Record; + if (typeof block.text === 'string') { + parts.push(block.text); + continue; + } + if (typeof block.content === 'string') { + parts.push(block.content); + } + } + return parts; +} + +/** Extract a short human-readable preview from tool_result content. */ +export function extractToolResultPreview(content: unknown, max = 80): string | undefined { + const joined = flattenToolResultContent(content).join(' ').replace(/\s+/g, ' ').trim(); + if (!joined) return undefined; + return truncateStr(joined, max); +} diff --git a/test/main/services/team/TeammateToolTracker.test.ts b/test/main/services/team/TeammateToolTracker.test.ts new file mode 100644 index 00000000..833744a9 --- /dev/null +++ b/test/main/services/team/TeammateToolTracker.test.ts @@ -0,0 +1,375 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { appendFile, mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { TeammateToolTracker } from '@main/services/team/TeammateToolTracker'; + +import type { TeamChangeEvent } from '@shared/types'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +async function createSubagentLog( + rootDir: string, + sessionId: string, + fileName = 'agent-worker.jsonl' +): Promise { + const subagentsDir = path.join(rootDir, sessionId, 'subagents'); + await mkdir(subagentsDir, { recursive: true }); + const filePath = path.join(subagentsDir, fileName); + await writeFile(filePath, '', 'utf8'); + return filePath; +} + +async function waitForCondition(check: () => void, attempts = 20): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + check(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('TeammateToolTracker', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('emits unresolved teammate tools on initial enable', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-')); + tempDirs.push(rootDir); + const filePath = await createSubagentLog(rootDir, 'session-a'); + + await writeFile( + filePath, + `${JSON.stringify({ + timestamp: '2026-03-28T10:00:00.000Z', + type: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }], + })}\n`, + 'utf8' + ); + + const listAttributedSubagentFiles = vi.fn(async () => [ + { + memberName: 'alice', + sessionId: 'session-a', + filePath, + mtimeMs: Date.now(), + }, + ]); + const setTracking = vi.fn(async () => ({ + projectFingerprint: null, + logSourceGeneration: null, + })); + const events: TeamChangeEvent[] = []; + + const tracker = new TeammateToolTracker( + { listAttributedSubagentFiles } as never, + { setTracking } as never, + (event) => events.push(event) + ); + + await tracker.setTracking('my-team', true); + + expect(setTracking).toHaveBeenCalledWith('my-team', 'tool_activity', true); + expect(events).toHaveLength(1); + const payload = JSON.parse(events[0].detail ?? ''); + expect(payload).toMatchObject({ + action: 'start', + activity: { + memberName: 'alice', + toolUseId: 'member_log:session-a:tool-1', + toolName: 'Read', + source: 'member_log', + }, + }); + }); + + it('emits finish when appended tool_result arrives and preserves chunk carry across boundaries', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-')); + tempDirs.push(rootDir); + const filePath = await createSubagentLog(rootDir, 'session-b'); + + await writeFile( + filePath, + `${JSON.stringify({ + timestamp: '2026-03-28T10:00:00.000Z', + type: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }], + })}\n`, + 'utf8' + ); + + const listAttributedSubagentFiles = vi.fn(async () => [ + { + memberName: 'alice', + sessionId: 'session-b', + filePath, + mtimeMs: Date.now(), + }, + ]); + const events: TeamChangeEvent[] = []; + const tracker = new TeammateToolTracker( + { listAttributedSubagentFiles } as never, + { setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, + (event) => events.push(event) + ); + + await tracker.setTracking('my-team', true); + expect(events).toHaveLength(1); + + const resultLine = JSON.stringify({ + timestamp: '2026-03-28T10:00:01.000Z', + type: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }], + }); + const splitAt = Math.floor(resultLine.length / 2); + await appendFile(filePath, resultLine.slice(0, splitAt), 'utf8'); + tracker.handleLogSourceChange('my-team'); + await waitForCondition(() => { + expect(events).toHaveLength(1); + }); + + await appendFile(filePath, `${resultLine.slice(splitAt)}\n`, 'utf8'); + tracker.handleLogSourceChange('my-team'); + await waitForCondition(() => { + expect(events).toHaveLength(2); + }); + const payload = JSON.parse(events[1].detail ?? ''); + expect(payload).toMatchObject({ + action: 'finish', + memberName: 'alice', + toolUseId: 'member_log:session-b:tool-1', + resultPreview: 'done', + }); + }); + + it('resets only removed file tools when one of multiple files disappears', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-')); + tempDirs.push(rootDir); + const firstFile = await createSubagentLog(rootDir, 'session-c', 'agent-a.jsonl'); + const secondFile = await createSubagentLog(rootDir, 'session-d', 'agent-b.jsonl'); + + const runningLine = (toolId: string) => + `${JSON.stringify({ + timestamp: '2026-03-28T10:00:00.000Z', + type: 'assistant', + content: [{ type: 'tool_use', id: toolId, name: 'Read', input: { file_path: `${toolId}.ts` } }], + })}\n`; + + await writeFile(firstFile, runningLine('tool-a'), 'utf8'); + await writeFile(secondFile, runningLine('tool-b'), 'utf8'); + + const attributedFiles = [ + { memberName: 'alice', sessionId: 'session-c', filePath: firstFile, mtimeMs: Date.now() }, + { memberName: 'alice', sessionId: 'session-d', filePath: secondFile, mtimeMs: Date.now() }, + ]; + const listAttributedSubagentFiles = vi.fn(async () => [...attributedFiles]); + const events: TeamChangeEvent[] = []; + const tracker = new TeammateToolTracker( + { listAttributedSubagentFiles } as never, + { setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, + (event) => events.push(event) + ); + + await tracker.setTracking('my-team', true); + expect(events).toHaveLength(2); + + attributedFiles.shift(); + tracker.handleLogSourceChange('my-team'); + await waitForCondition(() => { + expect(events).toHaveLength(3); + }); + + const payload = JSON.parse(events[2].detail ?? ''); + expect(payload).toMatchObject({ + action: 'reset', + memberName: 'alice', + toolUseIds: ['member_log:session-c:tool-a'], + }); + }); + + it('resets truncated file tools and replays only currently unresolved tools after full resync', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-')); + tempDirs.push(rootDir); + const filePath = await createSubagentLog(rootDir, 'session-e'); + + const toolALine = JSON.stringify({ + timestamp: '2026-03-28T10:00:00.000Z', + type: 'assistant', + content: [{ type: 'tool_use', id: 'tool-a', name: 'Read', input: { file_path: 'a.ts' } }], + }); + const toolAResult = JSON.stringify({ + timestamp: '2026-03-28T10:00:01.000Z', + type: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-a', content: 'done-a' }], + }); + await writeFile(filePath, `${toolALine}\n${toolAResult}\n`, 'utf8'); + + const listAttributedSubagentFiles = vi.fn(async () => [ + { + memberName: 'alice', + sessionId: 'session-e', + filePath, + mtimeMs: Date.now(), + }, + ]); + const events: TeamChangeEvent[] = []; + const tracker = new TeammateToolTracker( + { listAttributedSubagentFiles } as never, + { setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, + (event) => events.push(event) + ); + + await tracker.setTracking('my-team', true); + expect(events).toHaveLength(0); + + const toolBLine = JSON.stringify({ + timestamp: '2026-03-28T10:00:02.000Z', + type: 'assistant', + content: [{ type: 'tool_use', id: 'tool-b', name: 'Bash', input: { command: 'echo ok' } }], + }); + await writeFile(filePath, `${toolBLine}\n`, 'utf8'); + + tracker.handleLogSourceChange('my-team'); + await waitForCondition(() => { + expect(events).toHaveLength(1); + }); + + const payload = JSON.parse(events[0].detail ?? ''); + expect(payload).toMatchObject({ + action: 'start', + activity: { + memberName: 'alice', + toolUseId: 'member_log:session-e:tool-b', + toolName: 'Bash', + }, + }); + }); + + it('resets old ownership and replays unresolved tools when file attribution changes', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-')); + tempDirs.push(rootDir); + const filePath = await createSubagentLog(rootDir, 'session-f'); + + await writeFile( + filePath, + `${JSON.stringify({ + timestamp: '2026-03-28T10:00:00.000Z', + type: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }], + })}\n`, + 'utf8' + ); + + let currentMemberName = 'alice'; + const listAttributedSubagentFiles = vi.fn(async () => [ + { + memberName: currentMemberName, + sessionId: 'session-f', + filePath, + mtimeMs: Date.now(), + }, + ]); + const events: TeamChangeEvent[] = []; + const tracker = new TeammateToolTracker( + { listAttributedSubagentFiles } as never, + { setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never, + (event) => events.push(event) + ); + + await tracker.setTracking('my-team', true); + expect(events).toHaveLength(1); + expect(JSON.parse(events[0].detail ?? '')).toMatchObject({ + action: 'start', + activity: { memberName: 'alice', toolUseId: 'member_log:session-f:tool-1' }, + }); + + currentMemberName = 'bob'; + tracker.handleLogSourceChange('my-team'); + await waitForCondition(() => { + expect(events).toHaveLength(3); + }); + + const resetPayload = JSON.parse(events[1].detail ?? ''); + const replayPayload = JSON.parse(events[2].detail ?? ''); + expect(resetPayload).toMatchObject({ + action: 'reset', + memberName: 'alice', + toolUseIds: ['member_log:session-f:tool-1'], + }); + expect(replayPayload).toMatchObject({ + action: 'start', + activity: { memberName: 'bob', toolUseId: 'member_log:session-f:tool-1' }, + }); + }); + + it('drops late refresh results when tracking is disabled during in-flight scan', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-')); + tempDirs.push(rootDir); + const filePath = await createSubagentLog(rootDir, 'session-g'); + + await writeFile( + filePath, + `${JSON.stringify({ + timestamp: '2026-03-28T10:00:00.000Z', + type: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }], + })}\n`, + 'utf8' + ); + + const deferred = createDeferred< + Array<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }> + >(); + const listAttributedSubagentFiles = vi.fn(() => deferred.promise); + const setTracking = vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })); + const events: TeamChangeEvent[] = []; + const tracker = new TeammateToolTracker( + { listAttributedSubagentFiles } as never, + { setTracking } as never, + (event) => events.push(event) + ); + + const enablePromise = tracker.setTracking('my-team', true); + await Promise.resolve(); + const disablePromise = tracker.setTracking('my-team', false); + + deferred.resolve([ + { + memberName: 'alice', + sessionId: 'session-g', + filePath, + mtimeMs: Date.now(), + }, + ]); + + await Promise.all([enablePromise, disablePromise]); + + expect(events).toHaveLength(0); + expect(setTracking).toHaveBeenNthCalledWith(1, 'my-team', 'tool_activity', true); + expect(setTracking).toHaveBeenNthCalledWith(2, 'my-team', 'tool_activity', false); + }); +}); diff --git a/test/renderer/components/reviewDiffSafety.test.ts b/test/renderer/components/reviewDiffSafety.test.ts index 89933141..b5a67b84 100644 --- a/test/renderer/components/reviewDiffSafety.test.ts +++ b/test/renderer/components/reviewDiffSafety.test.ts @@ -26,6 +26,7 @@ describe('reviewDiffSafety', () => { newString: 'const a = 2;\n', timestamp: '2026-03-28T10:00:00.000Z', toolUseId: 'tool-1', + toolName: 'Edit', type: 'edit', replaceAll: false, isError: false, @@ -43,6 +44,7 @@ describe('reviewDiffSafety', () => { newString: 'a'.repeat(600 * 1024), timestamp: '2026-03-28T10:00:00.000Z', toolUseId: 'tool-2', + toolName: 'Write', type: 'write-update', replaceAll: false, isError: false, diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 1a882134..4703a21d 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ onTeamChangeCb: null as - | ((event: unknown, data: { type?: string; teamName: string }) => void) + | ((event: unknown, data: { type?: string; teamName: string; detail?: string }) => void) | null, onProvisioningProgressCb: null as | ((event: unknown, data: { runId: string; teamName: string }) => void) @@ -32,8 +32,11 @@ vi.mock('@renderer/api', () => ({ }, teams: { setChangePresenceTracking: vi.fn(async () => undefined), + setToolActivityTracking: vi.fn(async () => undefined), onTeamChange: vi.fn( - (cb: (event: unknown, data: { teamName: string }) => void): (() => void) => { + ( + cb: (event: unknown, data: { teamName: string; type?: string; detail?: string }) => void + ): (() => void) => { hoisted.onTeamChangeCb = cb; return () => { hoisted.onTeamChangeCb = null; @@ -347,4 +350,66 @@ describe('team change throttling', () => { expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); }); + + it('tracks visible team tabs for tool activity and disables tracking when tab disappears', async () => { + const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking); + setToolActivityTrackingSpy.mockClear(); + + cleanup?.(); + cleanup = initializeNotificationListeners(); + await vi.advanceTimersByTimeAsync(0); + + expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + await vi.advanceTimersByTimeAsync(0); + + expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); + }); + + it('applies targeted tool resets without clearing sibling tools', async () => { + useStore.setState({ + activeToolsByTeam: { + 'my-team': { + alice: { + 'tool-a': { + memberName: 'alice', + toolUseId: 'tool-a', + toolName: 'Read', + startedAt: '2026-03-28T10:00:00.000Z', + state: 'running', + source: 'runtime', + }, + 'tool-b': { + memberName: 'alice', + toolUseId: 'tool-b', + toolName: 'Bash', + startedAt: '2026-03-28T10:00:01.000Z', + state: 'running', + source: 'runtime', + }, + }, + }, + }, + } as never); + + hoisted.onTeamChangeCb?.({}, { + type: 'tool-activity', + teamName: 'my-team', + detail: JSON.stringify({ + action: 'reset', + memberName: 'alice', + toolUseIds: ['tool-a'], + }), + }); + + expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-a']).toBeUndefined(); + expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-b']).toBeDefined(); + }); }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 1764c682..b58f5639 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -497,6 +497,9 @@ describe('teamSlice actions', () => { currentProvisioningRunIdByTeam: { 'my-team': 'pending:my-team:1', }, + currentRuntimeRunIdByTeam: { + 'my-team': 'pending:my-team:1', + }, memberSpawnStatusesByTeam: { 'my-team': { alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' }, @@ -511,6 +514,7 @@ describe('teamSlice actions', () => { expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined(); expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); expect(store.getState().ignoredProvisioningRunIds['pending:my-team:1']).toBe('my-team'); + expect(store.getState().ignoredRuntimeRunIds['pending:my-team:1']).toBe('my-team'); }); it('does not resurrect a cleared missing run when late progress arrives', () => { @@ -591,6 +595,91 @@ describe('teamSlice actions', () => { expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); }); + it('tombstones the previous runtime run and clears tool layers before creating a new run', async () => { + const store = createSliceStore(); + store.setState({ + currentRuntimeRunIdByTeam: { + 'my-team': 'runtime-old', + }, + activeToolsByTeam: { + 'my-team': { + 'team-lead': { + 'tool-a': { + memberName: 'team-lead', + toolUseId: 'tool-a', + toolName: 'Read', + startedAt: '2026-03-12T10:00:00.000Z', + state: 'running', + source: 'runtime', + }, + }, + }, + }, + finishedVisibleByTeam: { + 'my-team': { + 'team-lead': { + 'tool-b': { + memberName: 'team-lead', + toolUseId: 'tool-b', + toolName: 'Bash', + startedAt: '2026-03-12T10:00:01.000Z', + finishedAt: '2026-03-12T10:00:02.000Z', + state: 'complete', + source: 'runtime', + }, + }, + }, + }, + toolHistoryByTeam: { + 'my-team': { + 'team-lead': [ + { + memberName: 'team-lead', + toolUseId: 'tool-b', + toolName: 'Bash', + startedAt: '2026-03-12T10:00:01.000Z', + finishedAt: '2026-03-12T10:00:02.000Z', + state: 'complete', + source: 'runtime', + }, + ], + }, + }, + }); + + await store.getState().createTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + members: [], + }); + + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-1'); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined(); + expect(store.getState().activeToolsByTeam['my-team']).toBeUndefined(); + expect(store.getState().finishedVisibleByTeam['my-team']).toBeUndefined(); + expect(store.getState().toolHistoryByTeam['my-team']).toBeUndefined(); + }); + + it('ignores tombstoned runtime spawn-status snapshots', async () => { + const store = createSliceStore(); + store.setState({ + ignoredRuntimeRunIds: { + 'runtime-old': 'my-team', + }, + }); + hoisted.getMemberSpawnStatuses.mockResolvedValue({ + runId: 'runtime-old', + statuses: { + alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' }, + }, + }); + + await store.getState().fetchMemberSpawnStatuses('my-team'); + + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); + }); + it('preserves current spawn statuses when clearing a non-canonical missing run', () => { const store = createSliceStore(); store.setState({