fix(team): deduplicate permission_request processing across all entry paths
Root cause: handleTeammatePermissionRequest was called from 3 paths (early inbox scan, Category 4 relay scan, stdout/native) but only the early scan checked processedPermissionRequestIds, causing duplicate ToolApprovalRequests and extra permission_responses. Fix: - Move processedPermissionRequestIds check INTO handleTeammatePermissionRequest so ALL callers are protected by the same dedup gate - Remove duplicate Category 4 scan that re-processed inbox messages (early scan already covers all messages including read=true) - Category 4 now only builds a filter Set to exclude permission_request from relay to lead
This commit is contained in:
parent
f286468dac
commit
e431cfd02c
23 changed files with 1404 additions and 77 deletions
|
|
@ -63,7 +63,7 @@ export interface GraphNode {
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
source: 'runtime' | 'inbox';
|
source: 'runtime' | 'member_log' | 'inbox';
|
||||||
};
|
};
|
||||||
/** Recent completed tool activity for popovers and secondary UI */
|
/** Recent completed tool activity for popovers and secondary UI */
|
||||||
recentTools?: Array<{
|
recentTools?: Array<{
|
||||||
|
|
@ -73,7 +73,7 @@ export interface GraphNode {
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
finishedAt: string;
|
finishedAt: string;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
source: 'runtime' | 'inbox';
|
source: 'runtime' | 'member_log' | 'inbox';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// ─── Task-specific ─────────────────────────────────────────────────────
|
// ─── Task-specific ─────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ import {
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
TeamLogSourceTracker,
|
TeamLogSourceTracker,
|
||||||
TeamMemberLogsFinder,
|
TeamMemberLogsFinder,
|
||||||
|
TeammateToolTracker,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
@ -784,6 +785,7 @@ function initializeServices(): void {
|
||||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||||
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
||||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||||
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
||||||
const taskBoundaryParser = new TaskBoundaryParser();
|
const taskBoundaryParser = new TaskBoundaryParser();
|
||||||
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
|
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
|
||||||
|
|
@ -839,13 +841,27 @@ function initializeServices(): void {
|
||||||
return getTeamControlApiBaseUrl();
|
return getTeamControlApiBaseUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
|
||||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||||
httpServer?.broadcast('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);
|
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
|
||||||
teamLogSourceTracker.setEmitter(teamChangeEmitter);
|
teamLogSourceTracker.setEmitter(teamChangeEmitter);
|
||||||
|
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||||
|
teammateToolTracker?.handleLogSourceChange(teamName);
|
||||||
|
});
|
||||||
|
|
||||||
// Allow SchedulerService to push schedule events to renderer
|
// Allow SchedulerService to push schedule events to renderer
|
||||||
schedulerService.setChangeEmitter((event) => {
|
schedulerService.setChangeEmitter((event) => {
|
||||||
|
|
@ -874,6 +890,7 @@ function initializeServices(): void {
|
||||||
teamProvisioningService,
|
teamProvisioningService,
|
||||||
teamMemberLogsFinder,
|
teamMemberLogsFinder,
|
||||||
memberStatsComputer,
|
memberStatsComputer,
|
||||||
|
teammateToolTracker ?? undefined,
|
||||||
{
|
{
|
||||||
rewire: rewireContextEvents,
|
rewire: rewireContextEvents,
|
||||||
full: onContextSwitched,
|
full: onContextSwitched,
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ import type {
|
||||||
SshConnectionManager,
|
SshConnectionManager,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
TeamMemberLogsFinder,
|
TeamMemberLogsFinder,
|
||||||
|
TeammateToolTracker,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
|
@ -127,6 +128,7 @@ export function initializeIpcHandlers(
|
||||||
teamProvisioningService: TeamProvisioningService,
|
teamProvisioningService: TeamProvisioningService,
|
||||||
teamMemberLogsFinder: TeamMemberLogsFinder,
|
teamMemberLogsFinder: TeamMemberLogsFinder,
|
||||||
memberStatsComputer: MemberStatsComputer,
|
memberStatsComputer: MemberStatsComputer,
|
||||||
|
teammateToolTracker: TeammateToolTracker | undefined,
|
||||||
contextCallbacks: {
|
contextCallbacks: {
|
||||||
rewire: (context: ServiceContext) => void;
|
rewire: (context: ServiceContext) => void;
|
||||||
full: (context: ServiceContext) => void;
|
full: (context: ServiceContext) => void;
|
||||||
|
|
@ -167,7 +169,8 @@ export function initializeIpcHandlers(
|
||||||
teamProvisioningService,
|
teamProvisioningService,
|
||||||
teamMemberLogsFinder,
|
teamMemberLogsFinder,
|
||||||
memberStatsComputer,
|
memberStatsComputer,
|
||||||
teamBackupService
|
teamBackupService,
|
||||||
|
teammateToolTracker
|
||||||
);
|
);
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import {
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
|
|
@ -110,6 +111,7 @@ import {
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberStatsComputer,
|
MemberStatsComputer,
|
||||||
|
TeammateToolTracker,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
TeamMemberLogsFinder,
|
TeamMemberLogsFinder,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
|
|
@ -272,6 +274,7 @@ let teamProvisioningService: TeamProvisioningService | null = null;
|
||||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||||
let teamBackupService: TeamBackupService | null = null;
|
let teamBackupService: TeamBackupService | null = null;
|
||||||
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
|
|
||||||
const attachmentStore = new TeamAttachmentStore();
|
const attachmentStore = new TeamAttachmentStore();
|
||||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
|
|
@ -300,13 +303,15 @@ export function initializeTeamHandlers(
|
||||||
provisioningService: TeamProvisioningService,
|
provisioningService: TeamProvisioningService,
|
||||||
logsFinder?: TeamMemberLogsFinder,
|
logsFinder?: TeamMemberLogsFinder,
|
||||||
statsComputer?: MemberStatsComputer,
|
statsComputer?: MemberStatsComputer,
|
||||||
backupService?: TeamBackupService
|
backupService?: TeamBackupService,
|
||||||
|
toolTracker?: TeammateToolTracker
|
||||||
): void {
|
): void {
|
||||||
teamDataService = service;
|
teamDataService = service;
|
||||||
teamProvisioningService = provisioningService;
|
teamProvisioningService = provisioningService;
|
||||||
teamMemberLogsFinder = logsFinder ?? null;
|
teamMemberLogsFinder = logsFinder ?? null;
|
||||||
memberStatsComputer = statsComputer ?? null;
|
memberStatsComputer = statsComputer ?? null;
|
||||||
teamBackupService = backupService ?? null;
|
teamBackupService = backupService ?? null;
|
||||||
|
teammateToolTracker = toolTracker ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
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_DATA, handleGetData);
|
||||||
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
||||||
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
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_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||||
|
|
@ -378,6 +384,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_GET_DATA);
|
ipcMain.removeHandler(TEAM_GET_DATA);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
||||||
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
||||||
|
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
||||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||||
ipcMain.removeHandler(TEAM_CREATE);
|
ipcMain.removeHandler(TEAM_CREATE);
|
||||||
|
|
@ -450,6 +457,13 @@ function getTeamProvisioningService(): TeamProvisioningService {
|
||||||
return teamProvisioningService;
|
return teamProvisioningService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTeammateToolTracker(): TeammateToolTracker {
|
||||||
|
if (!teammateToolTracker) {
|
||||||
|
throw new Error('Teammate tool tracker is not initialized');
|
||||||
|
}
|
||||||
|
return teammateToolTracker;
|
||||||
|
}
|
||||||
|
|
||||||
async function wrapTeamHandler<T>(
|
async function wrapTeamHandler<T>(
|
||||||
operation: string,
|
operation: string,
|
||||||
handler: () => Promise<T>
|
handler: () => Promise<T>
|
||||||
|
|
@ -655,6 +669,24 @@ async function handleSetChangePresenceTracking(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSetToolActivityTracking(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
enabled: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
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(
|
async function handleDeleteTeam(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
void this.teamLogSourceTracker
|
void this.teamLogSourceTracker
|
||||||
.ensureTracking(teamName)
|
.setTracking(teamName, 'change_presence', true)
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`)
|
logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`)
|
||||||
);
|
);
|
||||||
|
|
@ -205,7 +205,7 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.teamLogSourceTracker
|
void this.teamLogSourceTracker
|
||||||
.stopTracking(teamName)
|
.setTracking(teamName, 'change_presence', false)
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`)
|
logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ interface TeamLogSourceSnapshot {
|
||||||
logSourceGeneration: string | null;
|
logSourceGeneration: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity';
|
||||||
|
|
||||||
interface TrackingState {
|
interface TrackingState {
|
||||||
watcher: FSWatcher | null;
|
watcher: FSWatcher | null;
|
||||||
projectDir: string | null;
|
projectDir: string | null;
|
||||||
|
|
@ -29,13 +31,14 @@ interface TrackingState {
|
||||||
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||||
recomputeVersion: number | null;
|
recomputeVersion: number | null;
|
||||||
snapshot: TeamLogSourceSnapshot;
|
snapshot: TeamLogSourceSnapshot;
|
||||||
desiredTracking: boolean;
|
consumers: Set<TeamLogSourceTrackingConsumer>;
|
||||||
lifecycleVersion: number;
|
lifecycleVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamLogSourceTracker {
|
export class TeamLogSourceTracker {
|
||||||
private readonly stateByTeam = new Map<string, TrackingState>();
|
private readonly stateByTeam = new Map<string, TrackingState>();
|
||||||
private emitter: ((event: TeamChangeEvent) => void) | null = null;
|
private emitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||||
|
private readonly changeListeners = new Set<(teamName: string) => void>();
|
||||||
|
|
||||||
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
|
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
|
||||||
|
|
||||||
|
|
@ -43,22 +46,46 @@ export class TeamLogSourceTracker {
|
||||||
this.emitter = emitter;
|
this.emitter = emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onLogSourceChange(listener: (teamName: string) => void): () => void {
|
||||||
|
this.changeListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.changeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshot(teamName: string): TeamLogSourceSnapshot | null {
|
getSnapshot(teamName: string): TeamLogSourceSnapshot | null {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
return state ? { ...state.snapshot } : null;
|
return state ? { ...state.snapshot } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setTracking(
|
||||||
|
teamName: string,
|
||||||
|
consumer: TeamLogSourceTrackingConsumer,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<TeamLogSourceSnapshot> {
|
||||||
|
return enabled
|
||||||
|
? this.enableTracking(teamName, consumer)
|
||||||
|
: this.disableTracking(teamName, consumer);
|
||||||
|
}
|
||||||
|
|
||||||
async ensureTracking(teamName: string): Promise<TeamLogSourceSnapshot> {
|
async ensureTracking(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||||
|
return this.enableTracking(teamName, 'change_presence');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enableTracking(
|
||||||
|
teamName: string,
|
||||||
|
consumer: TeamLogSourceTrackingConsumer
|
||||||
|
): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.getOrCreateState(teamName);
|
const state = this.getOrCreateState(teamName);
|
||||||
if (!state.desiredTracking) {
|
if (!state.consumers.has(consumer)) {
|
||||||
state.desiredTracking = true;
|
state.consumers.add(consumer);
|
||||||
state.lifecycleVersion += 1;
|
state.lifecycleVersion += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
state.initializePromise &&
|
state.initializePromise &&
|
||||||
state.initializeVersion === state.lifecycleVersion &&
|
state.initializeVersion === state.lifecycleVersion &&
|
||||||
state.desiredTracking
|
state.consumers.size > 0
|
||||||
) {
|
) {
|
||||||
return state.initializePromise;
|
return state.initializePromise;
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +128,7 @@ export class TeamLogSourceTracker {
|
||||||
recomputePromise: null,
|
recomputePromise: null,
|
||||||
recomputeVersion: null,
|
recomputeVersion: null,
|
||||||
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
||||||
desiredTracking: false,
|
consumers: new Set(),
|
||||||
lifecycleVersion: 0,
|
lifecycleVersion: 0,
|
||||||
};
|
};
|
||||||
this.stateByTeam.set(teamName, created);
|
this.stateByTeam.set(teamName, created);
|
||||||
|
|
@ -109,16 +136,27 @@ export class TeamLogSourceTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopTracking(teamName: string): Promise<void> {
|
async stopTracking(teamName: string): Promise<void> {
|
||||||
|
await this.disableTracking(teamName, 'change_presence');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disableTracking(
|
||||||
|
teamName: string,
|
||||||
|
consumer: TeamLogSourceTrackingConsumer
|
||||||
|
): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return { projectFingerprint: null, logSourceGeneration: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.desiredTracking) {
|
if (state.consumers.has(consumer)) {
|
||||||
state.desiredTracking = false;
|
state.consumers.delete(consumer);
|
||||||
state.lifecycleVersion += 1;
|
state.lifecycleVersion += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.consumers.size > 0) {
|
||||||
|
return { ...state.snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
if (state.refreshTimer) {
|
if (state.refreshTimer) {
|
||||||
clearTimeout(state.refreshTimer);
|
clearTimeout(state.refreshTimer);
|
||||||
state.refreshTimer = null;
|
state.refreshTimer = null;
|
||||||
|
|
@ -131,11 +169,12 @@ export class TeamLogSourceTracker {
|
||||||
|
|
||||||
state.projectDir = null;
|
state.projectDir = null;
|
||||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||||
|
return { ...state.snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
||||||
const state = this.stateByTeam.get(teamName);
|
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(
|
private async initializeTeam(
|
||||||
|
|
@ -167,10 +206,7 @@ export class TeamLogSourceTracker {
|
||||||
state.snapshot.logSourceGeneration &&
|
state.snapshot.logSourceGeneration &&
|
||||||
previousGeneration !== state.snapshot.logSourceGeneration
|
previousGeneration !== state.snapshot.logSourceGeneration
|
||||||
) {
|
) {
|
||||||
this.emitter?.({
|
this.emitLogSourceChange(teamName);
|
||||||
type: 'log-source-change',
|
|
||||||
teamName,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +217,7 @@ export class TeamLogSourceTracker {
|
||||||
expectedVersion: number
|
expectedVersion: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) {
|
if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.projectDir === projectDir && state.watcher) {
|
if (state.projectDir === projectDir && state.watcher) {
|
||||||
|
|
@ -216,7 +252,7 @@ export class TeamLogSourceTracker {
|
||||||
|
|
||||||
const scheduleRecompute = (): void => {
|
const scheduleRecompute = (): void => {
|
||||||
const current = this.stateByTeam.get(teamName);
|
const current = this.stateByTeam.get(teamName);
|
||||||
if (!current?.desiredTracking) {
|
if (!current || current.consumers.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (current.refreshTimer) {
|
if (current.refreshTimer) {
|
||||||
|
|
@ -240,13 +276,13 @@ export class TeamLogSourceTracker {
|
||||||
|
|
||||||
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.getOrCreateState(teamName);
|
const state = this.getOrCreateState(teamName);
|
||||||
if (!state.desiredTracking) {
|
if (state.consumers.size === 0) {
|
||||||
return state.snapshot;
|
return state.snapshot;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
state.recomputePromise &&
|
state.recomputePromise &&
|
||||||
state.recomputeVersion === state.lifecycleVersion &&
|
state.recomputeVersion === state.lifecycleVersion &&
|
||||||
state.desiredTracking
|
state.consumers.size > 0
|
||||||
) {
|
) {
|
||||||
return state.recomputePromise;
|
return state.recomputePromise;
|
||||||
}
|
}
|
||||||
|
|
@ -278,10 +314,7 @@ export class TeamLogSourceTracker {
|
||||||
state.snapshot.logSourceGeneration &&
|
state.snapshot.logSourceGeneration &&
|
||||||
previousGeneration !== state.snapshot.logSourceGeneration
|
previousGeneration !== state.snapshot.logSourceGeneration
|
||||||
) {
|
) {
|
||||||
this.emitter?.({
|
this.emitLogSourceChange(teamName);
|
||||||
type: 'log-source-change',
|
|
||||||
teamName,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.snapshot;
|
return state.snapshot;
|
||||||
|
|
@ -298,6 +331,20 @@ export class TeamLogSourceTracker {
|
||||||
return recomputePromise;
|
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: {
|
private async computeSnapshot(context: {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const ATTRIBUTION_SCAN_LINES = 50;
|
||||||
/** Grace before task creation — logs cannot reference a task before it exists. */
|
/** Grace before task creation — logs cannot reference a task before it exists. */
|
||||||
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
||||||
const FILE_MENTIONS_CACHE_MAX = 10_000;
|
const FILE_MENTIONS_CACHE_MAX = 10_000;
|
||||||
|
const ATTRIBUTION_CACHE_MAX = 5_000;
|
||||||
|
|
||||||
/** Max concurrent file reads during parallel scan phases. */
|
/** Max concurrent file reads during parallel scan phases. */
|
||||||
const SCAN_CONCURRENCY = 15;
|
const SCAN_CONCURRENCY = 15;
|
||||||
|
|
@ -87,6 +88,7 @@ function trimTrailingSlashes(value: string): string {
|
||||||
|
|
||||||
export class TeamMemberLogsFinder {
|
export class TeamMemberLogsFinder {
|
||||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||||
|
private readonly attributionCache = new Map<string, SubagentAttribution | null>();
|
||||||
private readonly discoveryCache = new Map<
|
private readonly discoveryCache = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|
@ -660,7 +662,17 @@ export class TeamMemberLogsFinder {
|
||||||
|
|
||||||
const filePath = path.join(subagentsDir, file);
|
const filePath = path.join(subagentsDir, file);
|
||||||
// Quick attribution check — only Phase 1 (no full-file streaming)
|
// 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()) {
|
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
|
||||||
paths.push(filePath);
|
paths.push(filePath);
|
||||||
}
|
}
|
||||||
|
|
@ -670,6 +682,50 @@ export class TeamMemberLogsFinder {
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAttributedSubagentFiles(
|
||||||
|
teamName: string
|
||||||
|
): Promise<Array<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }>> {
|
||||||
|
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.
|
* Fast marker probe for task-related logs.
|
||||||
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
||||||
|
|
@ -1150,6 +1206,24 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getCachedSubagentAttribution(
|
||||||
|
filePath: string,
|
||||||
|
knownMembers: Set<string>,
|
||||||
|
mtimeMs: number
|
||||||
|
): Promise<SubagentAttribution | null> {
|
||||||
|
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(
|
private async parseSubagentSummary(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
|
||||||
|
|
@ -4046,11 +4046,7 @@ export class TeamProvisioningService {
|
||||||
if (typeof msg.text !== 'string') continue;
|
if (typeof msg.text !== 'string') continue;
|
||||||
const perm = parsePermissionRequest(msg.text);
|
const perm = parsePermissionRequest(msg.text);
|
||||||
if (!perm) continue;
|
if (!perm) continue;
|
||||||
if (run.processedPermissionRequestIds.has(perm.requestId)) continue;
|
// Dedup is handled inside handleTeammatePermissionRequest via processedPermissionRequestIds
|
||||||
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}`
|
|
||||||
);
|
|
||||||
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
|
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -4193,32 +4189,19 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
const deferredIds = new Set(deferredByAge.map((m) => m.messageId));
|
const deferredIds = new Set(deferredByAge.map((m) => m.messageId));
|
||||||
|
|
||||||
// Category 4: teammate permission requests — intercept and convert to tool approvals.
|
// Category 4: teammate permission requests — filter from actionable so they're
|
||||||
// Don't relay these to the lead agent (it can't handle them).
|
// NOT relayed to the lead. The actual interception + ToolApprovalRequest emission
|
||||||
// NOTE: We intentionally do NOT exclude nativeMatchedMessageIds here — even if
|
// is handled by the early scan above (which checks processedPermissionRequestIds).
|
||||||
// Claude Code runtime natively delivered the message to the lead, we still need
|
const permissionRequestIds = new Set(
|
||||||
// to intercept permission_request and show the ToolApprovalSheet for the user.
|
unread
|
||||||
const permissionRequestMsgs = unread.filter(
|
.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
!permanentlyIgnoredIds.has(m.messageId) &&
|
!permanentlyIgnoredIds.has(m.messageId) &&
|
||||||
!deferredIds.has(m.messageId) &&
|
!deferredIds.has(m.messageId) &&
|
||||||
parsePermissionRequest(m.text) !== null
|
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.
|
// Actionable: everything not in any category.
|
||||||
const actionableUnread = unread.filter(
|
const actionableUnread = unread.filter(
|
||||||
|
|
@ -5777,13 +5760,11 @@ export class TeamProvisioningService {
|
||||||
perm: ParsedPermissionRequest,
|
perm: ParsedPermissionRequest,
|
||||||
messageTimestamp: string
|
messageTimestamp: string
|
||||||
): void {
|
): void {
|
||||||
// Skip if already tracked (idempotency — relay can be called multiple times)
|
// Skip if already tracked (idempotency — multiple paths can trigger this:
|
||||||
if (run.pendingApprovals.has(perm.requestId)) {
|
// early inbox scan, stdout parsing, native message blocks, relay Category 4)
|
||||||
logger.warn(
|
if (run.processedPermissionRequestIds.has(perm.requestId)) return;
|
||||||
`[${run.teamName}] [PERM-TRACE] Duplicate permission_request skipped: ${perm.requestId}`
|
if (run.pendingApprovals.has(perm.requestId)) return;
|
||||||
);
|
run.processedPermissionRequestIds.add(perm.requestId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] [PERM-TRACE] handleTeammatePermissionRequest: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}`
|
`[${run.teamName}] [PERM-TRACE] handleTeammatePermissionRequest: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}`
|
||||||
|
|
|
||||||
520
src/main/services/team/TeammateToolTracker.ts
Normal file
520
src/main/services/team/TeammateToolTracker.ts
Normal file
|
|
@ -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<string, ActiveToolCall>;
|
||||||
|
seenFinished: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamState {
|
||||||
|
enabled: boolean;
|
||||||
|
epoch: number;
|
||||||
|
filesByPath: Map<string, FileState>;
|
||||||
|
refreshInFlight: boolean;
|
||||||
|
refreshQueued: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttributedSubagentFile {
|
||||||
|
memberName: string;
|
||||||
|
sessionId: string;
|
||||||
|
filePath: string;
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedFileSnapshot {
|
||||||
|
lastSize: number;
|
||||||
|
lastMtimeMs: number;
|
||||||
|
lineCarry: string;
|
||||||
|
activeTools: Map<string, ActiveToolCall>;
|
||||||
|
seenFinished: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeammateToolTracker {
|
||||||
|
private readonly stateByTeam = new Map<string, TeamState>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly logsFinder: TeamMemberLogsFinder,
|
||||||
|
private readonly logSourceTracker: TeamLogSourceTracker,
|
||||||
|
private readonly emitTeamChange: (event: TeamChangeEvent) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async setTracking(teamName: string, enabled: boolean): Promise<void> {
|
||||||
|
if (enabled) {
|
||||||
|
await this.enableTracking(teamName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.disableTracking(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ParsedFileSnapshot> {
|
||||||
|
const content = await fs.readFile(file.filePath, 'utf8').catch(() => '');
|
||||||
|
const { lines, carry } = splitJsonLines(content);
|
||||||
|
const activeTools = new Map<string, ActiveToolCall>();
|
||||||
|
const seenFinished = new Set<string>();
|
||||||
|
|
||||||
|
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<string, ActiveToolCall>();
|
||||||
|
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<FileState> {
|
||||||
|
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<string, ActiveToolCall>,
|
||||||
|
seenFinished: Set<string>,
|
||||||
|
emitters?: {
|
||||||
|
emitStart?: (activity: ActiveToolCall) => void;
|
||||||
|
emitFinish?: (activity: ActiveToolCall, result: FinishPayload) => void;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
let entry: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} 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<string, unknown>;
|
||||||
|
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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
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<string, FileState>): 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<string, unknown>): Record<string, unknown>[] | null {
|
||||||
|
if (Array.isArray(entry.content)) return entry.content as Record<string, unknown>[];
|
||||||
|
const message = entry.message;
|
||||||
|
if (
|
||||||
|
message &&
|
||||||
|
typeof message === 'object' &&
|
||||||
|
Array.isArray((message as { content?: unknown[] }).content)
|
||||||
|
) {
|
||||||
|
return (message as { content: Record<string, unknown>[] }).content;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEntryTimestamp(entry: Record<string, unknown>): 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<string> {
|
||||||
|
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<string>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ export { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
||||||
export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
export { TeamMemberResolver } from './TeamMemberResolver';
|
export { TeamMemberResolver } from './TeamMemberResolver';
|
||||||
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
export { TeammateToolTracker } from './TeammateToolTracker';
|
||||||
export { TeamProvisioningService } from './TeamProvisioningService';
|
export { TeamProvisioningService } from './TeamProvisioningService';
|
||||||
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
export { TeamTaskReader } from './TeamTaskReader';
|
export { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/** Enable or disable task change presence tracking for a visible team tab */
|
||||||
export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking';
|
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) */
|
/** Get buffered Claude CLI logs (paged, newest-first) */
|
||||||
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ import {
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
|
|
@ -812,6 +813,9 @@ const electronAPI: ElectronAPI = {
|
||||||
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
|
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
|
||||||
},
|
},
|
||||||
|
setToolActivityTracking: async (teamName: string, enabled: boolean) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled);
|
||||||
|
},
|
||||||
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
|
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
|
||||||
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
|
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -676,6 +676,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
setChangePresenceTracking: async (): Promise<void> => {
|
setChangePresenceTracking: async (): Promise<void> => {
|
||||||
// Not available in browser mode — no-op.
|
// Not available in browser mode — no-op.
|
||||||
},
|
},
|
||||||
|
setToolActivityTracking: async (): Promise<void> => {
|
||||||
|
// Not available in browser mode — no-op.
|
||||||
|
},
|
||||||
getClaudeLogs: async (
|
getClaudeLogs: async (
|
||||||
_teamName: string,
|
_teamName: string,
|
||||||
_query?: TeamClaudeLogsQuery
|
_query?: TeamClaudeLogsQuery
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import {
|
||||||
Command,
|
Command,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
|
MoveRight,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Reply,
|
Reply,
|
||||||
X,
|
X,
|
||||||
|
|
@ -835,9 +836,7 @@ export const ActivityItem = memo(
|
||||||
{/* Recipient — arrow + avatar + badge */}
|
{/* Recipient — arrow + avatar + badge */}
|
||||||
{message.to && message.to !== message.from ? (
|
{message.to && message.to !== message.from ? (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||||
→
|
|
||||||
</span>
|
|
||||||
{crossTeamTarget ? (
|
{crossTeamTarget ? (
|
||||||
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={onTeamClick} />
|
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={onTeamClick} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,21 @@ export function initializeNotificationListeners(): () => void {
|
||||||
delete nextTeamState[memberName];
|
delete nextTeamState[memberName];
|
||||||
return nextTeamState;
|
return nextTeamState;
|
||||||
};
|
};
|
||||||
|
const removeMemberToolEntries = (
|
||||||
|
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
|
||||||
|
memberName: string,
|
||||||
|
toolUseIds: readonly string[]
|
||||||
|
): Record<string, Record<string, ActiveToolCall>> => {
|
||||||
|
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 => {
|
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
|
||||||
if (!projectId) return null;
|
if (!projectId) return null;
|
||||||
const separatorIndex = projectId.indexOf('::');
|
const separatorIndex = projectId.indexOf('::');
|
||||||
|
|
@ -461,6 +476,19 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return new Set([selectedTeamName]);
|
return new Set([selectedTeamName]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTrackedToolActivityTeams = (): Set<string> => {
|
||||||
|
const { paneLayout } = useStore.getState();
|
||||||
|
const tracked = new Set<string>();
|
||||||
|
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) {
|
if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) {
|
||||||
let trackedTeamNames = new Set<string>();
|
let trackedTeamNames = new Set<string>();
|
||||||
const syncVisibleTeamTracking = (): void => {
|
const syncVisibleTeamTracking = (): void => {
|
||||||
|
|
@ -503,6 +531,44 @@ export function initializeNotificationListeners(): () => void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (api.teams?.setToolActivityTracking) {
|
||||||
|
let trackedTeamNames = new Set<string>();
|
||||||
|
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
|
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||||
if (api.onTodoChange) {
|
if (api.onTodoChange) {
|
||||||
const cleanup = api.onTodoChange((event) => {
|
const cleanup = api.onTodoChange((event) => {
|
||||||
|
|
@ -801,6 +867,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
} else if (payload.action === 'reset') {
|
} else if (payload.action === 'reset') {
|
||||||
if (payload.memberName) {
|
if (payload.memberName) {
|
||||||
const memberName = payload.memberName;
|
const memberName = payload.memberName;
|
||||||
|
const toolUseIds =
|
||||||
|
Array.isArray(payload.toolUseIds) && payload.toolUseIds.length > 0
|
||||||
|
? payload.toolUseIds
|
||||||
|
: null;
|
||||||
useStore.setState((prev) => {
|
useStore.setState((prev) => {
|
||||||
if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) {
|
if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) {
|
||||||
return {};
|
return {};
|
||||||
|
|
@ -808,10 +878,13 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return {
|
return {
|
||||||
activeToolsByTeam: {
|
activeToolsByTeam: {
|
||||||
...prev.activeToolsByTeam,
|
...prev.activeToolsByTeam,
|
||||||
[event.teamName]: removeMemberToolGroup(
|
[event.teamName]: toolUseIds
|
||||||
prev.activeToolsByTeam[event.teamName],
|
? removeMemberToolEntries(
|
||||||
memberName
|
prev.activeToolsByTeam[event.teamName],
|
||||||
),
|
memberName,
|
||||||
|
toolUseIds
|
||||||
|
)
|
||||||
|
: removeMemberToolGroup(prev.activeToolsByTeam[event.teamName], memberName),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ const sanitizeSchema: SanitizeSchema = {
|
||||||
// Allow title on abbr (for tooltip definitions)
|
// Allow title on abbr (for tooltip definitions)
|
||||||
abbr: [...(defaultSchema.attributes?.abbr ?? []), 'title'],
|
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 */
|
/** Full plugin chain: raw HTML → sanitize → syntax highlighting */
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,7 @@ export interface TeamsAPI {
|
||||||
getData: (teamName: string) => Promise<TeamData>;
|
getData: (teamName: string) => Promise<TeamData>;
|
||||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||||
|
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||||
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
|
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
restoreTeam: (teamName: string) => Promise<void>;
|
restoreTeam: (teamName: string) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ export interface ToolCallMeta {
|
||||||
toolUseId?: string;
|
toolUseId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolActivitySource = 'runtime' | 'inbox';
|
export type ToolActivitySource = 'runtime' | 'member_log' | 'inbox';
|
||||||
export type ToolActivityState = 'running' | 'complete' | 'error';
|
export type ToolActivityState = 'running' | 'complete' | 'error';
|
||||||
|
|
||||||
/** Live or recently finished tool activity for one team member. */
|
/** Live or recently finished tool activity for one team member. */
|
||||||
|
|
@ -337,6 +337,7 @@ export interface ToolActivityEventPayload {
|
||||||
};
|
};
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
toolUseId?: string;
|
toolUseId?: string;
|
||||||
|
toolUseIds?: string[];
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export function extractToolPreview(
|
||||||
case 'Glob':
|
case 'Glob':
|
||||||
return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
|
return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
|
||||||
case 'Agent':
|
case 'Agent':
|
||||||
|
case 'Task':
|
||||||
case 'TaskCreate':
|
case 'TaskCreate':
|
||||||
return typeof input.prompt === 'string'
|
return typeof input.prompt === 'string'
|
||||||
? input.prompt
|
? 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<string, unknown>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
375
test/main/services/team/TeammateToolTracker.test.ts
Normal file
375
test/main/services/team/TeammateToolTracker.test.ts
Normal file
|
|
@ -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<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSubagentLog(
|
||||||
|
rootDir: string,
|
||||||
|
sessionId: string,
|
||||||
|
fileName = 'agent-worker.jsonl'
|
||||||
|
): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -26,6 +26,7 @@ describe('reviewDiffSafety', () => {
|
||||||
newString: 'const a = 2;\n',
|
newString: 'const a = 2;\n',
|
||||||
timestamp: '2026-03-28T10:00:00.000Z',
|
timestamp: '2026-03-28T10:00:00.000Z',
|
||||||
toolUseId: 'tool-1',
|
toolUseId: 'tool-1',
|
||||||
|
toolName: 'Edit',
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
replaceAll: false,
|
replaceAll: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
|
|
@ -43,6 +44,7 @@ describe('reviewDiffSafety', () => {
|
||||||
newString: 'a'.repeat(600 * 1024),
|
newString: 'a'.repeat(600 * 1024),
|
||||||
timestamp: '2026-03-28T10:00:00.000Z',
|
timestamp: '2026-03-28T10:00:00.000Z',
|
||||||
toolUseId: 'tool-2',
|
toolUseId: 'tool-2',
|
||||||
|
toolName: 'Write',
|
||||||
type: 'write-update',
|
type: 'write-update',
|
||||||
replaceAll: false,
|
replaceAll: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
onTeamChangeCb: null as
|
onTeamChangeCb: null as
|
||||||
| ((event: unknown, data: { type?: string; teamName: string }) => void)
|
| ((event: unknown, data: { type?: string; teamName: string; detail?: string }) => void)
|
||||||
| null,
|
| null,
|
||||||
onProvisioningProgressCb: null as
|
onProvisioningProgressCb: null as
|
||||||
| ((event: unknown, data: { runId: string; teamName: string }) => void)
|
| ((event: unknown, data: { runId: string; teamName: string }) => void)
|
||||||
|
|
@ -32,8 +32,11 @@ vi.mock('@renderer/api', () => ({
|
||||||
},
|
},
|
||||||
teams: {
|
teams: {
|
||||||
setChangePresenceTracking: vi.fn(async () => undefined),
|
setChangePresenceTracking: vi.fn(async () => undefined),
|
||||||
|
setToolActivityTracking: vi.fn(async () => undefined),
|
||||||
onTeamChange: vi.fn(
|
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;
|
hoisted.onTeamChangeCb = cb;
|
||||||
return () => {
|
return () => {
|
||||||
hoisted.onTeamChangeCb = null;
|
hoisted.onTeamChangeCb = null;
|
||||||
|
|
@ -347,4 +350,66 @@ describe('team change throttling', () => {
|
||||||
|
|
||||||
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -497,6 +497,9 @@ describe('teamSlice actions', () => {
|
||||||
currentProvisioningRunIdByTeam: {
|
currentProvisioningRunIdByTeam: {
|
||||||
'my-team': 'pending:my-team:1',
|
'my-team': 'pending:my-team:1',
|
||||||
},
|
},
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'pending:my-team:1',
|
||||||
|
},
|
||||||
memberSpawnStatusesByTeam: {
|
memberSpawnStatusesByTeam: {
|
||||||
'my-team': {
|
'my-team': {
|
||||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
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().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
|
||||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
|
||||||
expect(store.getState().ignoredProvisioningRunIds['pending:my-team:1']).toBe('my-team');
|
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', () => {
|
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();
|
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', () => {
|
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue