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:
iliya 2026-03-28 20:32:42 +02:00
parent f286468dac
commit e431cfd02c
23 changed files with 1404 additions and 77 deletions

View file

@ -63,7 +63,7 @@ export interface GraphNode {
startedAt: string;
finishedAt?: string;
resultPreview?: string;
source: 'runtime' | 'inbox';
source: 'runtime' | 'member_log' | 'inbox';
};
/** Recent completed tool activity for popovers and secondary UI */
recentTools?: Array<{
@ -73,7 +73,7 @@ export interface GraphNode {
startedAt: string;
finishedAt: string;
resultPreview?: string;
source: 'runtime' | 'inbox';
source: 'runtime' | 'member_log' | 'inbox';
}>;
// ─── Task-specific ─────────────────────────────────────────────────────

View file

@ -106,6 +106,7 @@ import {
TeamDataService,
TeamLogSourceTracker,
TeamMemberLogsFinder,
TeammateToolTracker,
TeamProvisioningService,
UpdaterService,
} from './services';
@ -784,6 +785,7 @@ function initializeServices(): void {
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
let teammateToolTracker: TeammateToolTracker | null = null;
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
const taskBoundaryParser = new TaskBoundaryParser();
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
@ -839,13 +841,27 @@ function initializeServices(): void {
return getTeamControlApiBaseUrl();
});
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
const teamChangeEmitter = (event: TeamChangeEvent): void => {
const forwardTeamChange = (event: TeamChangeEvent): void => {
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
httpServer?.broadcast('team-change', event);
};
teammateToolTracker = new TeammateToolTracker(
teamMemberLogsFinder,
teamLogSourceTracker,
forwardTeamChange
);
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
const teamChangeEmitter = (event: TeamChangeEvent): void => {
forwardTeamChange(event);
if (event.type === 'lead-activity' && event.detail === 'offline') {
teammateToolTracker?.handleTeamOffline(event.teamName);
}
};
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
teamLogSourceTracker.setEmitter(teamChangeEmitter);
teamLogSourceTracker.onLogSourceChange((teamName) => {
teammateToolTracker?.handleLogSourceChange(teamName);
});
// Allow SchedulerService to push schedule events to renderer
schedulerService.setChangeEmitter((event) => {
@ -874,6 +890,7 @@ function initializeServices(): void {
teamProvisioningService,
teamMemberLogsFinder,
memberStatsComputer,
teammateToolTracker ?? undefined,
{
rewire: rewireContextEvents,
full: onContextSwitched,

View file

@ -100,6 +100,7 @@ import type {
SshConnectionManager,
TeamDataService,
TeamMemberLogsFinder,
TeammateToolTracker,
TeamProvisioningService,
UpdaterService,
} from '../services';
@ -127,6 +128,7 @@ export function initializeIpcHandlers(
teamProvisioningService: TeamProvisioningService,
teamMemberLogsFinder: TeamMemberLogsFinder,
memberStatsComputer: MemberStatsComputer,
teammateToolTracker: TeammateToolTracker | undefined,
contextCallbacks: {
rewire: (context: ServiceContext) => void;
full: (context: ServiceContext) => void;
@ -167,7 +169,8 @@ export function initializeIpcHandlers(
teamProvisioningService,
teamMemberLogsFinder,
memberStatsComputer,
teamBackupService
teamBackupService,
teammateToolTracker
);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,

View file

@ -48,6 +48,7 @@ import {
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
@ -110,6 +111,7 @@ import {
import type {
MemberStatsComputer,
TeammateToolTracker,
TeamDataService,
TeamMemberLogsFinder,
TeamProvisioningService,
@ -272,6 +274,7 @@ let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null;
let teamBackupService: TeamBackupService | null = null;
let teammateToolTracker: TeammateToolTracker | null = null;
const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore();
@ -300,13 +303,15 @@ export function initializeTeamHandlers(
provisioningService: TeamProvisioningService,
logsFinder?: TeamMemberLogsFinder,
statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService
backupService?: TeamBackupService,
toolTracker?: TeammateToolTracker
): void {
teamDataService = service;
teamProvisioningService = provisioningService;
teamMemberLogsFinder = logsFinder ?? null;
memberStatsComputer = statsComputer ?? null;
teamBackupService = backupService ?? null;
teammateToolTracker = toolTracker ?? null;
}
export function registerTeamHandlers(ipcMain: IpcMain): void {
@ -314,6 +319,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_DATA, handleGetData);
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
@ -378,6 +384,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_DATA);
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_CREATE);
@ -450,6 +457,13 @@ function getTeamProvisioningService(): TeamProvisioningService {
return teamProvisioningService;
}
function getTeammateToolTracker(): TeammateToolTracker {
if (!teammateToolTracker) {
throw new Error('Teammate tool tracker is not initialized');
}
return teammateToolTracker;
}
async function wrapTeamHandler<T>(
operation: string,
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(
_event: IpcMainInvokeEvent,
teamName: unknown

View file

@ -197,7 +197,7 @@ export class TeamDataService {
if (enabled) {
void this.teamLogSourceTracker
.ensureTracking(teamName)
.setTracking(teamName, 'change_presence', true)
.catch((error) =>
logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`)
);
@ -205,7 +205,7 @@ export class TeamDataService {
}
void this.teamLogSourceTracker
.stopTracking(teamName)
.setTracking(teamName, 'change_presence', false)
.catch((error) =>
logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`)
);

View file

@ -20,6 +20,8 @@ interface TeamLogSourceSnapshot {
logSourceGeneration: string | null;
}
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity';
interface TrackingState {
watcher: FSWatcher | null;
projectDir: string | null;
@ -29,13 +31,14 @@ interface TrackingState {
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
recomputeVersion: number | null;
snapshot: TeamLogSourceSnapshot;
desiredTracking: boolean;
consumers: Set<TeamLogSourceTrackingConsumer>;
lifecycleVersion: number;
}
export class TeamLogSourceTracker {
private readonly stateByTeam = new Map<string, TrackingState>();
private emitter: ((event: TeamChangeEvent) => void) | null = null;
private readonly changeListeners = new Set<(teamName: string) => void>();
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
@ -43,22 +46,46 @@ export class TeamLogSourceTracker {
this.emitter = emitter;
}
onLogSourceChange(listener: (teamName: string) => void): () => void {
this.changeListeners.add(listener);
return () => {
this.changeListeners.delete(listener);
};
}
getSnapshot(teamName: string): TeamLogSourceSnapshot | null {
const state = this.stateByTeam.get(teamName);
return state ? { ...state.snapshot } : null;
}
async setTracking(
teamName: string,
consumer: TeamLogSourceTrackingConsumer,
enabled: boolean
): Promise<TeamLogSourceSnapshot> {
return enabled
? this.enableTracking(teamName, consumer)
: this.disableTracking(teamName, consumer);
}
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);
if (!state.desiredTracking) {
state.desiredTracking = true;
if (!state.consumers.has(consumer)) {
state.consumers.add(consumer);
state.lifecycleVersion += 1;
}
if (
state.initializePromise &&
state.initializeVersion === state.lifecycleVersion &&
state.desiredTracking
state.consumers.size > 0
) {
return state.initializePromise;
}
@ -101,7 +128,7 @@ export class TeamLogSourceTracker {
recomputePromise: null,
recomputeVersion: null,
snapshot: { projectFingerprint: null, logSourceGeneration: null },
desiredTracking: false,
consumers: new Set(),
lifecycleVersion: 0,
};
this.stateByTeam.set(teamName, created);
@ -109,16 +136,27 @@ export class TeamLogSourceTracker {
}
async stopTracking(teamName: string): Promise<void> {
await this.disableTracking(teamName, 'change_presence');
}
private async disableTracking(
teamName: string,
consumer: TeamLogSourceTrackingConsumer
): Promise<TeamLogSourceSnapshot> {
const state = this.stateByTeam.get(teamName);
if (!state) {
return;
return { projectFingerprint: null, logSourceGeneration: null };
}
if (state.desiredTracking) {
state.desiredTracking = false;
if (state.consumers.has(consumer)) {
state.consumers.delete(consumer);
state.lifecycleVersion += 1;
}
if (state.consumers.size > 0) {
return { ...state.snapshot };
}
if (state.refreshTimer) {
clearTimeout(state.refreshTimer);
state.refreshTimer = null;
@ -131,11 +169,12 @@ export class TeamLogSourceTracker {
state.projectDir = null;
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
return { ...state.snapshot };
}
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
const state = this.stateByTeam.get(teamName);
return !!state && state.desiredTracking && state.lifecycleVersion === expectedVersion;
return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion;
}
private async initializeTeam(
@ -167,10 +206,7 @@ export class TeamLogSourceTracker {
state.snapshot.logSourceGeneration &&
previousGeneration !== state.snapshot.logSourceGeneration
) {
this.emitter?.({
type: 'log-source-change',
teamName,
});
this.emitLogSourceChange(teamName);
}
return snapshot;
}
@ -181,7 +217,7 @@ export class TeamLogSourceTracker {
expectedVersion: number
): Promise<void> {
const state = this.stateByTeam.get(teamName);
if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) {
if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) {
return;
}
if (state.projectDir === projectDir && state.watcher) {
@ -216,7 +252,7 @@ export class TeamLogSourceTracker {
const scheduleRecompute = (): void => {
const current = this.stateByTeam.get(teamName);
if (!current?.desiredTracking) {
if (!current || current.consumers.size === 0) {
return;
}
if (current.refreshTimer) {
@ -240,13 +276,13 @@ export class TeamLogSourceTracker {
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (!state.desiredTracking) {
if (state.consumers.size === 0) {
return state.snapshot;
}
if (
state.recomputePromise &&
state.recomputeVersion === state.lifecycleVersion &&
state.desiredTracking
state.consumers.size > 0
) {
return state.recomputePromise;
}
@ -278,10 +314,7 @@ export class TeamLogSourceTracker {
state.snapshot.logSourceGeneration &&
previousGeneration !== state.snapshot.logSourceGeneration
) {
this.emitter?.({
type: 'log-source-change',
teamName,
});
this.emitLogSourceChange(teamName);
}
return state.snapshot;
@ -298,6 +331,20 @@ export class TeamLogSourceTracker {
return recomputePromise;
}
private emitLogSourceChange(teamName: string): void {
this.emitter?.({
type: 'log-source-change',
teamName,
});
for (const listener of this.changeListeners) {
try {
listener(teamName);
} catch (error) {
logger.warn(`Log-source listener failed for ${teamName}: ${String(error)}`);
}
}
}
private async computeSnapshot(context: {
projectDir: string;
projectPath?: string;

View file

@ -25,6 +25,7 @@ const ATTRIBUTION_SCAN_LINES = 50;
/** Grace before task creation — logs cannot reference a task before it exists. */
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
const FILE_MENTIONS_CACHE_MAX = 10_000;
const ATTRIBUTION_CACHE_MAX = 5_000;
/** Max concurrent file reads during parallel scan phases. */
const SCAN_CONCURRENCY = 15;
@ -87,6 +88,7 @@ function trimTrailingSlashes(value: string): string {
export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>();
private readonly attributionCache = new Map<string, SubagentAttribution | null>();
private readonly discoveryCache = new Map<
string,
{
@ -660,7 +662,17 @@ export class TeamMemberLogsFinder {
const filePath = path.join(subagentsDir, file);
// Quick attribution check — only Phase 1 (no full-file streaming)
const attribution = await this.attributeSubagent(filePath, knownMembers);
let mtimeMs = 0;
try {
mtimeMs = (await fs.stat(filePath)).mtimeMs;
} catch {
continue;
}
const attribution = await this.getCachedSubagentAttribution(
filePath,
knownMembers,
mtimeMs
);
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
paths.push(filePath);
}
@ -670,6 +682,50 @@ export class TeamMemberLogsFinder {
return paths;
}
async listAttributedSubagentFiles(
teamName: string
): Promise<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.
* 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(
filePath: string,
projectId: string,

View file

@ -4046,11 +4046,7 @@ export class TeamProvisioningService {
if (typeof msg.text !== 'string') continue;
const perm = parsePermissionRequest(msg.text);
if (!perm) continue;
if (run.processedPermissionRequestIds.has(perm.requestId)) continue;
run.processedPermissionRequestIds.add(perm.requestId);
logger.warn(
`[${run.teamName}] [PERM-TRACE] Intercepted permission_request from inbox scan (read=${String(msg.read)}): agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}`
);
// Dedup is handled inside handleTeammatePermissionRequest via processedPermissionRequestIds
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
}
} catch {
@ -4193,32 +4189,19 @@ export class TeamProvisioningService {
);
const deferredIds = new Set(deferredByAge.map((m) => m.messageId));
// Category 4: teammate permission requests — intercept and convert to tool approvals.
// Don't relay these to the lead agent (it can't handle them).
// NOTE: We intentionally do NOT exclude nativeMatchedMessageIds here — even if
// Claude Code runtime natively delivered the message to the lead, we still need
// to intercept permission_request and show the ToolApprovalSheet for the user.
const permissionRequestMsgs = unread.filter(
(m) =>
!permanentlyIgnoredIds.has(m.messageId) &&
!deferredIds.has(m.messageId) &&
parsePermissionRequest(m.text) !== null
// Category 4: teammate permission requests — filter from actionable so they're
// NOT relayed to the lead. The actual interception + ToolApprovalRequest emission
// is handled by the early scan above (which checks processedPermissionRequestIds).
const permissionRequestIds = new Set(
unread
.filter(
(m) =>
!permanentlyIgnoredIds.has(m.messageId) &&
!deferredIds.has(m.messageId) &&
parsePermissionRequest(m.text) !== null
)
.map((m) => m.messageId)
);
const permissionRequestIds = new Set(permissionRequestMsgs.map((m) => m.messageId));
if (permissionRequestMsgs.length > 0) {
logger.warn(
`[${run.teamName}] [PERM-TRACE] relay intercepted ${permissionRequestMsgs.length} permission_request(s) from inbox`
);
for (const msg of permissionRequestMsgs) {
const perm = parsePermissionRequest(msg.text)!;
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
}
try {
await this.markInboxMessagesRead(teamName, leadName, permissionRequestMsgs);
} catch {
// best-effort
}
}
// Actionable: everything not in any category.
const actionableUnread = unread.filter(
@ -5777,13 +5760,11 @@ export class TeamProvisioningService {
perm: ParsedPermissionRequest,
messageTimestamp: string
): void {
// Skip if already tracked (idempotency — relay can be called multiple times)
if (run.pendingApprovals.has(perm.requestId)) {
logger.warn(
`[${run.teamName}] [PERM-TRACE] Duplicate permission_request skipped: ${perm.requestId}`
);
return;
}
// Skip if already tracked (idempotency — multiple paths can trigger this:
// early inbox scan, stdout parsing, native message blocks, relay Category 4)
if (run.processedPermissionRequestIds.has(perm.requestId)) return;
if (run.pendingApprovals.has(perm.requestId)) return;
run.processedPermissionRequestIds.add(perm.requestId);
logger.warn(
`[${run.teamName}] [PERM-TRACE] handleTeammatePermissionRequest: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}`

View 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);
}
}

View file

@ -20,6 +20,7 @@ export { TeamLogSourceTracker } from './TeamLogSourceTracker';
export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
export { TeamMemberResolver } from './TeamMemberResolver';
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
export { TeammateToolTracker } from './TeammateToolTracker';
export { TeamProvisioningService } from './TeamProvisioningService';
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
export { TeamTaskReader } from './TeamTaskReader';

View file

@ -216,6 +216,9 @@ export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence';
/** Enable or disable task change presence tracking for a visible team tab */
export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking';
/** Enable or disable live teammate tool activity tracking for a visible team tab */
export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking';
/** Get buffered Claude CLI logs (paged, newest-first) */
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';

View file

@ -150,6 +150,7 @@ import {
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
@ -812,6 +813,9 @@ const electronAPI: ElectronAPI = {
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
return invokeIpcWithResult<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) => {
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
},

View file

@ -676,6 +676,9 @@ export class HttpAPIClient implements ElectronAPI {
setChangePresenceTracking: async (): Promise<void> => {
// Not available in browser mode — no-op.
},
setToolActivityTracking: async (): Promise<void> => {
// Not available in browser mode — no-op.
},
getClaudeLogs: async (
_teamName: string,
_query?: TeamClaudeLogsQuery

View file

@ -54,6 +54,7 @@ import {
Command,
ListPlus,
Maximize2,
MoveRight,
RefreshCw,
Reply,
X,
@ -835,9 +836,7 @@ export const ActivityItem = memo(
{/* Recipient — arrow + avatar + badge */}
{message.to && message.to !== message.from ? (
<>
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
&rarr;
</span>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
{crossTeamTarget ? (
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={onTeamClick} />
) : null}

View file

@ -277,6 +277,21 @@ export function initializeNotificationListeners(): () => void {
delete nextTeamState[memberName];
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 => {
if (!projectId) return null;
const separatorIndex = projectId.indexOf('::');
@ -461,6 +476,19 @@ export function initializeNotificationListeners(): () => void {
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) {
let trackedTeamNames = new Set<string>();
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
if (api.onTodoChange) {
const cleanup = api.onTodoChange((event) => {
@ -801,6 +867,10 @@ export function initializeNotificationListeners(): () => void {
} else if (payload.action === 'reset') {
if (payload.memberName) {
const memberName = payload.memberName;
const toolUseIds =
Array.isArray(payload.toolUseIds) && payload.toolUseIds.length > 0
? payload.toolUseIds
: null;
useStore.setState((prev) => {
if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) {
return {};
@ -808,10 +878,13 @@ export function initializeNotificationListeners(): () => void {
return {
activeToolsByTeam: {
...prev.activeToolsByTeam,
[event.teamName]: removeMemberToolGroup(
prev.activeToolsByTeam[event.teamName],
memberName
),
[event.teamName]: toolUseIds
? removeMemberToolEntries(
prev.activeToolsByTeam[event.teamName],
memberName,
toolUseIds
)
: removeMemberToolGroup(prev.activeToolsByTeam[event.teamName], memberName),
},
};
});

View file

@ -39,6 +39,11 @@ const sanitizeSchema: SanitizeSchema = {
// Allow title on abbr (for tooltip definitions)
abbr: [...(defaultSchema.attributes?.abbr ?? []), 'title'],
},
protocols: {
...defaultSchema.protocols,
// Allow internal-only protocols used for mention badges, team badges, and task tooltips
href: [...(defaultSchema.protocols?.href ?? []), 'mention', 'team', 'task'],
},
};
/** Full plugin chain: raw HTML → sanitize → syntax highlighting */

View file

@ -419,6 +419,7 @@ export interface TeamsAPI {
getData: (teamName: string) => Promise<TeamData>;
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;

View file

@ -308,7 +308,7 @@ export interface ToolCallMeta {
toolUseId?: string;
}
export type ToolActivitySource = 'runtime' | 'inbox';
export type ToolActivitySource = 'runtime' | 'member_log' | 'inbox';
export type ToolActivityState = 'running' | 'complete' | 'error';
/** Live or recently finished tool activity for one team member. */
@ -337,6 +337,7 @@ export interface ToolActivityEventPayload {
};
memberName?: string;
toolUseId?: string;
toolUseIds?: string[];
finishedAt?: string;
resultPreview?: string;
isError?: boolean;

View file

@ -93,6 +93,7 @@ export function extractToolPreview(
case 'Glob':
return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
case 'Agent':
case 'Task':
case 'TaskCreate':
return typeof input.prompt === 'string'
? input.prompt
@ -116,3 +117,34 @@ export function extractToolPreview(
}
}
}
function flattenToolResultContent(content: unknown): string[] {
if (typeof content === 'string') {
return [content];
}
if (!Array.isArray(content)) {
return [];
}
const parts: string[] = [];
for (const item of content) {
if (!item || typeof item !== 'object') continue;
const block = item as Record<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);
}

View 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);
});
});

View file

@ -26,6 +26,7 @@ describe('reviewDiffSafety', () => {
newString: 'const a = 2;\n',
timestamp: '2026-03-28T10:00:00.000Z',
toolUseId: 'tool-1',
toolName: 'Edit',
type: 'edit',
replaceAll: false,
isError: false,
@ -43,6 +44,7 @@ describe('reviewDiffSafety', () => {
newString: 'a'.repeat(600 * 1024),
timestamp: '2026-03-28T10:00:00.000Z',
toolUseId: 'tool-2',
toolName: 'Write',
type: 'write-update',
replaceAll: false,
isError: false,

View file

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
onTeamChangeCb: null as
| ((event: unknown, data: { type?: string; teamName: string }) => void)
| ((event: unknown, data: { type?: string; teamName: string; detail?: string }) => void)
| null,
onProvisioningProgressCb: null as
| ((event: unknown, data: { runId: string; teamName: string }) => void)
@ -32,8 +32,11 @@ vi.mock('@renderer/api', () => ({
},
teams: {
setChangePresenceTracking: vi.fn(async () => undefined),
setToolActivityTracking: vi.fn(async () => undefined),
onTeamChange: vi.fn(
(cb: (event: unknown, data: { teamName: string }) => void): (() => void) => {
(
cb: (event: unknown, data: { teamName: string; type?: string; detail?: string }) => void
): (() => void) => {
hoisted.onTeamChangeCb = cb;
return () => {
hoisted.onTeamChangeCb = null;
@ -347,4 +350,66 @@ describe('team change throttling', () => {
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
});
it('tracks visible team tabs for tool activity and disables tracking when tab disappears', async () => {
const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking);
setToolActivityTrackingSpy.mockClear();
cleanup?.();
cleanup = initializeNotificationListeners();
await vi.advanceTimersByTimeAsync(0);
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true);
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
},
} as never);
await vi.advanceTimersByTimeAsync(0);
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
});
it('applies targeted tool resets without clearing sibling tools', async () => {
useStore.setState({
activeToolsByTeam: {
'my-team': {
alice: {
'tool-a': {
memberName: 'alice',
toolUseId: 'tool-a',
toolName: 'Read',
startedAt: '2026-03-28T10:00:00.000Z',
state: 'running',
source: 'runtime',
},
'tool-b': {
memberName: 'alice',
toolUseId: 'tool-b',
toolName: 'Bash',
startedAt: '2026-03-28T10:00:01.000Z',
state: 'running',
source: 'runtime',
},
},
},
},
} as never);
hoisted.onTeamChangeCb?.({}, {
type: 'tool-activity',
teamName: 'my-team',
detail: JSON.stringify({
action: 'reset',
memberName: 'alice',
toolUseIds: ['tool-a'],
}),
});
expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-a']).toBeUndefined();
expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-b']).toBeDefined();
});
});

View file

@ -497,6 +497,9 @@ describe('teamSlice actions', () => {
currentProvisioningRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
currentRuntimeRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
memberSpawnStatusesByTeam: {
'my-team': {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
@ -511,6 +514,7 @@ describe('teamSlice actions', () => {
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(store.getState().ignoredProvisioningRunIds['pending:my-team:1']).toBe('my-team');
expect(store.getState().ignoredRuntimeRunIds['pending:my-team:1']).toBe('my-team');
});
it('does not resurrect a cleared missing run when late progress arrives', () => {
@ -591,6 +595,91 @@ describe('teamSlice actions', () => {
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
});
it('tombstones the previous runtime run and clears tool layers before creating a new run', async () => {
const store = createSliceStore();
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-old',
},
activeToolsByTeam: {
'my-team': {
'team-lead': {
'tool-a': {
memberName: 'team-lead',
toolUseId: 'tool-a',
toolName: 'Read',
startedAt: '2026-03-12T10:00:00.000Z',
state: 'running',
source: 'runtime',
},
},
},
},
finishedVisibleByTeam: {
'my-team': {
'team-lead': {
'tool-b': {
memberName: 'team-lead',
toolUseId: 'tool-b',
toolName: 'Bash',
startedAt: '2026-03-12T10:00:01.000Z',
finishedAt: '2026-03-12T10:00:02.000Z',
state: 'complete',
source: 'runtime',
},
},
},
},
toolHistoryByTeam: {
'my-team': {
'team-lead': [
{
memberName: 'team-lead',
toolUseId: 'tool-b',
toolName: 'Bash',
startedAt: '2026-03-12T10:00:01.000Z',
finishedAt: '2026-03-12T10:00:02.000Z',
state: 'complete',
source: 'runtime',
},
],
},
},
});
await store.getState().createTeam({
teamName: 'my-team',
cwd: '/tmp/project',
members: [],
});
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-1');
expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined();
expect(store.getState().activeToolsByTeam['my-team']).toBeUndefined();
expect(store.getState().finishedVisibleByTeam['my-team']).toBeUndefined();
expect(store.getState().toolHistoryByTeam['my-team']).toBeUndefined();
});
it('ignores tombstoned runtime spawn-status snapshots', async () => {
const store = createSliceStore();
store.setState({
ignoredRuntimeRunIds: {
'runtime-old': 'my-team',
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({
runId: 'runtime-old',
statuses: {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
});
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
});
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
const store = createSliceStore();
store.setState({