import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; import { createReadStream } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; import { canonicalizeAgentTeamsToolName, lineHasAgentTeamsTaskBoundaryToolName, } from './agentTeamsToolNames'; import { choosePreferredLaunchSnapshot, readBootstrapLaunchSnapshot, } from './TeamBootstrapStateReader'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { buildTeamLogWatchSessionIds, extractRuntimeSessionIds } from './teamLogSourceWatchScope'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; import type { MemberLogSummary, MemberSessionLogSummary, MemberSubagentLogSummary, } from '@shared/types'; const logger = createLogger('Service:TeamMemberLogsFinder'); /** * Phase 1: How many lines to scan for member attribution. * Detection signals (process.team.memberName, "You are {name}", routing.sender) * appear in the first ~10 lines, so 50 is very conservative. */ 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 TASK_MENTION_INDEX_CACHE_MAX = 1_000; const ATTRIBUTION_CACHE_MAX = 5_000; /** Max concurrent file reads during parallel scan phases. */ const SCAN_CONCURRENCY = 15; /** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */ const DISCOVERY_CACHE_TTL = 30_000; const MAX_TASK_FRESHNESS_ROOT_DIRS = 64; /** Signal sources for subagent member attribution, ordered by reliability. */ type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention'; interface DetectionSignal { member: string; source: AttributionSignalSource; } /** * Precedence order for attribution signals (most reliable first). * - process_team: from system init message — written by CLI, definitive * - routing_sender: from toolUseResult.routing — identifies the actual agent * - teammate_id: from XML — identifies the message SENDER, not the agent * - text_mention: regex match of member name in text — lowest reliability */ const SIGNAL_PRECEDENCE: readonly AttributionSignalSource[] = [ 'process_team', 'routing_sender', 'teammate_id', 'text_mention', ]; interface StreamedMetadata { firstTimestamp: string | null; lastTimestamp: string | null; messageCount: number; lastOutputPreview: string | null; lastThinkingPreview: string | null; /** Recent thinking/output previews with timestamps for task-scoped filtering. */ recentPreviews: { text: string; timestamp: string; kind: 'thinking' | 'output' }[]; } /** Result of attributing a subagent file to a team member. */ interface SubagentAttribution { detectedMember: string; description: string; firstTimestamp: string | null; } interface RootSessionAttribution { detectedMember: string; description: string; firstTimestamp: string | null; } interface ProjectSessionDiscovery { projectDir: string; projectId: string; config: NonNullable>>; sessionIds: string[]; knownMembers: Set; } interface ProjectSessionDiscoveryOptions { forceRefresh?: boolean; includeTeamSubagentSessionDiscovery?: boolean; } interface TaskMentionIndex { exactTaskIds: Set; lowerTaskIds: Set; } type LogCandidate = | { kind: 'subagent'; filePath: string; sessionId: string; fileName: string; } | { kind: 'member_session'; filePath: string; sessionId: string; fileName: string; }; export interface MemberLogFileRef { memberName: string; sessionId: string; filePath: string; mtimeMs: number; sizeBytes?: number; messageCount?: number; kind?: 'lead_session' | 'member_session' | 'subagent'; } type FindRecentMemberLogFileRefsOptions = | number | null | { mtimeSinceMs?: number | null; forceRefresh?: boolean; includeTeamSubagentSessionDiscovery?: boolean; }; export interface TeamLogSourceLiveContext { projectDir: string; projectPath?: string; taskFreshnessRootDirs?: string[]; leadSessionId?: string; sessionIds: string[]; watchSessionIds: string[]; } async function mapLimit( items: readonly T[], limit: number, fn: (item: T, index: number) => Promise ): Promise { const results = new Array(items.length); let index = 0; const workerCount = Math.max(1, Math.min(limit, items.length)); const workers = new Array(workerCount).fill(0).map(async () => { while (true) { const currentIndex = index; index += 1; if (currentIndex >= items.length) { return; } results[currentIndex] = await fn(items[currentIndex], currentIndex); } }); await Promise.all(workers); return results; } function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[] { const roots: string[] = []; const seen = new Set(); for (const candidate of candidates) { if (typeof candidate !== 'string') { continue; } const trimmed = candidate.trim(); if (!trimmed || !path.isAbsolute(trimmed)) { continue; } const normalized = path.normalize(trimmed); if (seen.has(normalized)) { continue; } seen.add(normalized); roots.push(normalized); if (roots.length >= MAX_TASK_FRESHNESS_ROOT_DIRS) { break; } } return roots; } function buildProjectSessionDiscoveryCacheKey( teamName: string, options?: ProjectSessionDiscoveryOptions ): string { const subagentMode = options?.includeTeamSubagentSessionDiscovery === false ? 'known' : 'full'; return `${teamName}\0${subagentMode}`; } export class TeamMemberLogsFinder { private readonly taskMentionIndexCache = new Map(); private readonly taskMentionIndexInFlight = new Map>(); private readonly attributionCache = new Map< string, SubagentAttribution | RootSessionAttribution | null >(); private readonly attributionInFlight = new Map< string, Promise >(); private readonly discoveryCache = new Map< string, { result: NonNullable>>; expiresAt: number; } >(); private readonly discoveryInFlight = new Map< string, { generation: number; promise: Promise; forceRefresh: boolean; } >(); private readonly discoveryGenerationByTeam = new Map(); constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( { getConfig: (teamName) => configReader.getConfigSnapshot(teamName), } ), private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore() ) {} async findMemberLogs( teamName: string, memberName: string, mtimeSinceMs?: number | null ): Promise { const discovery = await this.discoverMemberFiles(teamName, memberName); if (!discovery) return []; const { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember } = discovery; const results: MemberLogSummary[] = []; const leadMemberName = config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; if (isLeadMember && config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); const leadSummary = await this.parseLeadSessionSummary( leadJsonl, projectId, config.leadSessionId, leadMemberName ); if (leadSummary) { results.push(leadSummary); } } // ── Collect and parallel-scan subagent files ── const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const settled = Array(candidates.length).fill(null); let nextIdx = 0; const scanWorker = async (): Promise => { while (nextIdx < candidates.length) { const idx = nextIdx++; const c = candidates[idx]; try { // Skip files older than the caller's time window (cheap fs.stat, no file read) if (mtimeSinceMs != null) { try { const stat = await fs.stat(c.filePath); if (stat.mtimeMs < mtimeSinceMs) continue; } catch { continue; } } const summary = c.kind === 'subagent' ? await this.parseSubagentSummary( c.filePath, projectId, c.sessionId, c.fileName, memberName, knownMembers ) : await this.parseMemberSessionSummary( c.filePath, projectId, c.sessionId, memberName, teamName, knownMembers ); if (summary) settled[idx] = summary; } catch (err) { logger.warn(`Failed to parse member log summary: ${c.filePath}`, err); } } }; await Promise.all( Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker()) ); for (const s of settled) { if (s) results.push(s); } return results.sort( (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() ); } async getLogSourceWatchContext( teamName: string, options?: { forceRefresh?: boolean } ): Promise<{ projectDir: string; projectPath?: string; leadSessionId?: string; sessionIds: string[]; } | null> { if (options?.forceRefresh) { this.discoveryCache.delete(teamName); } const discovery = await this.discoverProjectSessions(teamName, options); if (!discovery) { return null; } return { projectDir: discovery.projectDir, projectPath: discovery.config.projectPath, leadSessionId: discovery.config.leadSessionId, sessionIds: [...discovery.sessionIds], }; } async getLiveLogSourceWatchContext( teamName: string, options?: { forceRefresh?: boolean } ): Promise { const [launchSnapshot, bootstrapSnapshot] = await Promise.all([ this.launchStateStore.read(teamName).catch(() => null), readBootstrapLaunchSnapshot(teamName).catch(() => null), ]); const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot); const runtimeMemberCwdCandidates = Object.values(preferredSnapshot?.members ?? {}).map( (member) => member.cwd ); const base = await this.projectResolver.getLiveBaseContext(teamName, { forceRefresh: options?.forceRefresh, extraProjectPathCandidates: runtimeMemberCwdCandidates, }); if (!base) { return null; } const watchSessionIds = buildTeamLogWatchSessionIds({ configLeadSessionId: base.config.leadSessionId, launchLeadSessionId: preferredSnapshot?.leadSessionId, sessionHistory: base.config.sessionHistory, launchRuntimeSessionIds: extractRuntimeSessionIds(preferredSnapshot), }); return { projectDir: base.projectDir, projectPath: base.config.projectPath, taskFreshnessRootDirs: collectTaskFreshnessRootDirs([ base.config.projectPath, ...(base.config.members ?? []).map((member) => member.cwd), ...runtimeMemberCwdCandidates, ]), leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId, sessionIds: watchSessionIds, watchSessionIds, }; } /** * Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.). * When the task is in_progress and has an owner, also includes that owner's session logs so * the executor's current activity is visible even before the JSONL mentions the task id. */ async findLogsForTask( teamName: string, taskId: string, options?: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; } ): Promise { const t0 = performance.now(); const discovery = await this.discoverProjectSessions(teamName); const tDiscovery = performance.now(); if (!discovery) { console.log( `[perf] findLogsForTask(${taskId}) discovery=null ${(tDiscovery - t0).toFixed(0)}ms` ); return []; } const sinceMs = this.deriveSinceMs(options); const { projectDir, projectId, config, sessionIds, knownMembers } = discovery; const results: MemberLogSummary[] = []; const leadMemberName = config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; if (config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { await fs.access(leadJsonl); if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) { const leadSummary = await this.parseLeadSessionSummary( leadJsonl, projectId, config.leadSessionId, leadMemberName ); if (leadSummary) results.push(leadSummary); } } catch { // file missing or unreadable } } // ── Collect all subagent file candidates ── const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── const settled = Array(candidates.length).fill(null); let nextIdx = 0; let mentionHits = 0; const scanWorker = async (): Promise => { while (nextIdx < candidates.length) { const idx = nextIdx++; const c = candidates[idx]; try { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; mentionHits++; const summary = c.kind === 'subagent' ? await (async (): Promise => { const attribution = await this.attributeSubagent(c.filePath, knownMembers); if (!attribution) return null; return this.parseSubagentSummary( c.filePath, projectId, c.sessionId, c.fileName, attribution.detectedMember, knownMembers, attribution ); })() : await (async (): Promise => { const attribution = await this.attributeMemberSession( c.filePath, teamName, knownMembers ); if (!attribution) return null; return this.parseMemberSessionSummary( c.filePath, projectId, c.sessionId, attribution.detectedMember, teamName, knownMembers, attribution ); })(); if (summary) settled[idx] = summary; } catch (err) { logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; await Promise.all( Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker()) ); for (const s of settled) { if (s) results.push(s); } const totalFiles = candidates.length; const step2Count = results.length; // count before step 3 (owner fallback) const normalizedOwner = typeof options?.owner === 'string' ? options.owner.trim() : options?.owner; const isLeadOwner = typeof normalizedOwner === 'string' && normalizedOwner.length > 0 && normalizedOwner.toLowerCase() === leadMemberName.toLowerCase(); const ownerRelevantStatus = options?.status === 'in_progress' || options?.status === 'completed'; const includeOwnerSessions = ownerRelevantStatus && typeof normalizedOwner === 'string' && normalizedOwner.length > 0 && !isLeadOwner; if (includeOwnerSessions) { const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner, sinceMs); const TASK_LOG_INTERVAL_GRACE_MS = 10_000; const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history const now = Date.now(); const normalizedIntervals = Array.isArray(options?.intervals) ? options.intervals .map((i) => { const startMs = Date.parse(i.startedAt); const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; const endMs = i.completedAt === undefined ? null : Number.isFinite(endMsRaw) ? Math.max(endMsRaw, startMs) : startMs; return Number.isFinite(startMs) ? { startMs, endMs } : null; }) .filter((v): v is { startMs: number; endMs: number | null } => v !== null) : []; // Back-compat: single since timestamp -> treat as open interval. const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN; const sinceStartMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null; const effectiveIntervals = normalizedIntervals.length > 0 ? normalizedIntervals : sinceStartMs != null ? [{ startMs: sinceStartMs, endMs: null }] : []; const filteredOwnerLogs = ownerLogs.filter((log) => { const startMs = new Date(log.startTime).getTime(); if (!Number.isFinite(startMs)) return false; const durationMs = typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0; const rawEndMs = startMs + durationMs; const endMs = log.isOngoing ? Math.max(rawEndMs, now) : rawEndMs; if (effectiveIntervals.length > 0) { return this.logOverlapsIntervals( startMs, endMs, effectiveIntervals, now, TASK_LOG_INTERVAL_GRACE_MS ); } if (log.isOngoing) return true; return startMs >= now - fallbackRecentMs; }); const seen = new Set(); for (const log of results) { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` : log.kind === 'member_session' ? `member:${log.sessionId}` : `lead:${log.sessionId}`; seen.add(key); } for (const log of filteredOwnerLogs) { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` : log.kind === 'member_session' ? `member:${log.sessionId}` : `lead:${log.sessionId}`; if (!seen.has(key)) { seen.add(key); results.push(log); } } } // Dedup cumulative subagent snapshots: keep 1 file per sessionId+memberName (largest). // In-process teammates produce cumulative JSONL files where each successive file // contains ALL lines from the previous + a new delta. The largest file is a superset. const preDedupCount = results.length; { const subagentsByKey = new Map(); const nonSubagent: MemberLogSummary[] = []; for (const r of results) { if (r.kind !== 'subagent') { nonSubagent.push(r); continue; } const memberKey = r.memberName ? r.memberName.toLowerCase() : `_${r.subagentId}`; const key = `${r.sessionId}:${memberKey}`; const existing = subagentsByKey.get(key); if (!existing || r.messageCount > existing.messageCount) { subagentsByKey.set(key, r); } } results.length = 0; results.push(...nonSubagent, ...subagentsByKey.values()); } // NOTE: dedup assumes cumulative snapshots (largest file = superset of all smaller ones). // Safety net: filterChunksByWorkIntervals on frontend still filters content by time, // so even if the wrong file is picked, only task-relevant chunks are shown. const sorted = [...results].sort( (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() ); const tTotal = performance.now(); console.log( `[findLogsForTask] task=${taskId}@${teamName} | ` + `step2=${step2Count} (scan ${mentionHits}/${totalFiles} files) | ` + `step3=${preDedupCount - step2Count} (owner=${normalizedOwner ?? 'none'}, includeOwner=${includeOwnerSessions}) | ` + `dedup=${preDedupCount}→${sorted.length} | ` + `total=${sorted.length} | ` + `${(tTotal - t0).toFixed(0)}ms` ); return sorted; } /** * Fast path for change extraction: returns task-related JSONL file refs directly without * building full MemberLogSummary metadata for every matched log. */ async findLogFileRefsForTask( teamName: string, taskId: string, options?: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; } ): Promise<{ filePath: string; memberName: string }[]> { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) { return []; } const sinceMs = this.deriveSinceMs(options); const { projectDir, config, sessionIds, knownMembers } = discovery; const refs: { kind: LogCandidate['kind'] | 'lead_session'; filePath: string; memberName: string; sessionId: string; sortTime: number; }[] = []; const seen = new Set(); const leadMemberName = config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; const pushRef = ( filePath: string, memberName: string, sortTime = 0, kind: LogCandidate['kind'] | 'lead_session' = 'member_session', sessionId = '' ): void => { const key = `${kind}:${sessionId}:${memberName.toLowerCase()}:${filePath}`; if (seen.has(key)) return; seen.add(key); refs.push({ kind, filePath, memberName, sessionId, sortTime }); }; if (config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { await fs.access(leadJsonl); if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) { const firstTimestamp = await this.probeFirstTimestamp(leadJsonl); pushRef( leadJsonl, leadMemberName, await this.getSortTime(leadJsonl, firstTimestamp), 'lead_session', config.leadSessionId ); } } catch { // file missing or unreadable } } // ── Collect all subagent file candidates ── const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── let nextIdx = 0; const scanWorker = async (): Promise => { while (nextIdx < candidates.length) { const idx = nextIdx++; const c = candidates[idx]; try { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; const attribution = c.kind === 'subagent' ? await this.attributeSubagent(c.filePath, knownMembers) : await this.attributeMemberSession(c.filePath, teamName, knownMembers); if (!attribution) continue; pushRef( c.filePath, attribution.detectedMember, await this.getSortTime(c.filePath, attribution.firstTimestamp), c.kind, c.sessionId ); } catch (err) { logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; await Promise.all( Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker()) ); const normalizedOwner = typeof options?.owner === 'string' ? options.owner.trim() : options?.owner; const isLeadOwner = typeof normalizedOwner === 'string' && normalizedOwner.length > 0 && normalizedOwner.toLowerCase() === leadMemberName.toLowerCase(); const ownerRelevantStatus = options?.status === 'in_progress' || options?.status === 'completed'; const includeOwnerSessions = ownerRelevantStatus && typeof normalizedOwner === 'string' && normalizedOwner.length > 0 && !isLeadOwner; if (includeOwnerSessions) { const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner, sinceMs); const TASK_LOG_INTERVAL_GRACE_MS = 10_000; const fallbackRecentMs = 30 * 60_000; const now = Date.now(); const normalizedIntervals = Array.isArray(options?.intervals) ? options.intervals .map((i) => { const startMs = Date.parse(i.startedAt); const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; const endMs = i.completedAt === undefined ? null : Number.isFinite(endMsRaw) ? Math.max(endMsRaw, startMs) : startMs; return Number.isFinite(startMs) ? { startMs, endMs } : null; }) .filter((v): v is { startMs: number; endMs: number | null } => v !== null) : []; const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN; const sinceStartMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null; const effectiveIntervals = normalizedIntervals.length > 0 ? normalizedIntervals : sinceStartMs != null ? [{ startMs: sinceStartMs, endMs: null }] : []; for (const log of ownerLogs) { if (!log.filePath) continue; const startMs = new Date(log.startTime).getTime(); if (!Number.isFinite(startMs)) continue; const durationMs = typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0; const rawEndMs = startMs + durationMs; const endMs = log.isOngoing ? Math.max(rawEndMs, now) : rawEndMs; if (effectiveIntervals.length > 0) { if ( !this.logOverlapsIntervals( startMs, endMs, effectiveIntervals, now, TASK_LOG_INTERVAL_GRACE_MS ) ) { continue; } } else if (!log.isOngoing && startMs < now - fallbackRecentMs) { continue; } pushRef( log.filePath, log.memberName ?? normalizedOwner, Number.isFinite(new Date(log.startTime).getTime()) ? new Date(log.startTime).getTime() : 0, log.kind === 'lead_session' ? 'lead_session' : log.kind, log.sessionId ); } } // Dedup cumulative subagent snapshots (same logic as findLogsForTask). { const refsByKey = new Map(); const leadRefs: (typeof refs)[0][] = []; const memberSessionRefsByKey = new Map(); for (const ref of refs) { if (ref.kind === 'lead_session') { leadRefs.push(ref); continue; } if (ref.kind === 'member_session') { const key = `member:${ref.sessionId}:${ref.memberName.toLowerCase()}`; const existing = memberSessionRefsByKey.get(key); if (!existing || ref.sortTime > existing.sortTime) { memberSessionRefsByKey.set(key, ref); } continue; } const key = `${ref.sessionId}:${ref.memberName.toLowerCase()}`; const existing = refsByKey.get(key); if (!existing || ref.sortTime > existing.sortTime) { refsByKey.set(key, ref); } } refs.length = 0; refs.push(...leadRefs, ...memberSessionRefsByKey.values(), ...refsByKey.values()); } const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); return sortedRefs.map(({ filePath, memberName }) => ({ filePath, memberName })); } /** * Returns absolute paths to all JSONL files belonging to the specified member. * Uses the same discovery logic as findMemberLogs but collects file paths. */ async findMemberLogPaths(teamName: string, memberName: string): Promise { const discovery = await this.discoverMemberFiles(teamName, memberName); if (!discovery) return []; const { projectDir, config, sessionIds, knownMembers, isLeadMember } = discovery; const paths: string[] = []; if (isLeadMember && config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { await fs.access(leadJsonl); paths.push(leadJsonl); } catch { // File doesn't exist } } const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); for (const candidate of candidates) { let mtimeMs = 0; try { mtimeMs = (await fs.stat(candidate.filePath)).mtimeMs; } catch { continue; } const attribution = candidate.kind === 'subagent' ? await this.getCachedSubagentAttribution(candidate.filePath, knownMembers, mtimeMs) : await this.getCachedMemberSessionAttribution( candidate.filePath, teamName, knownMembers, mtimeMs ); if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { paths.push(candidate.filePath); } } return paths; } async listAttributedMemberFiles( teamName: string ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) return []; const { projectDir, sessionIds, knownMembers, config } = discovery; const currentLeadSessionId = typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 ? config.leadSessionId.trim() : null; const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const results: { memberName: string; sessionId: string; filePath: string; mtimeMs: number; }[] = []; const settled = await Promise.all( candidates.map(async (candidate) => { try { if ( candidate.kind === 'subagent' && currentLeadSessionId && candidate.sessionId !== currentLeadSessionId ) { return null; } const stat = await fs.stat(candidate.filePath); const attribution = candidate.kind === 'subagent' ? await this.getCachedSubagentAttribution( candidate.filePath, knownMembers, stat.mtimeMs ) : await this.getCachedMemberSessionAttribution( candidate.filePath, teamName, 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); } const latestRootSessionsByMember = new Map< string, { memberName: string; sessionId: string; filePath: string; mtimeMs: number } >(); const passthrough: typeof results = []; for (const item of results) { if ( !item.filePath.endsWith('.jsonl') || item.filePath.includes(`${path.sep}subagents${path.sep}`) ) { passthrough.push(item); continue; } const key = item.memberName.toLowerCase(); const existing = latestRootSessionsByMember.get(key); if (!existing || item.mtimeMs > existing.mtimeMs) { latestRootSessionsByMember.set(key, item); } } return [...passthrough, ...latestRootSessionsByMember.values()]; } async listAttributedSubagentFiles( teamName: string ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { return this.listAttributedMemberFiles(teamName); } async findRecentMemberLogFileRefsByMember( teamName: string, memberNames: readonly string[], options?: FindRecentMemberLogFileRefsOptions ): Promise { const parsedOptions = typeof options === 'number' || options === null ? { mtimeSinceMs: options ?? null, forceRefresh: false, includeTeamSubagentSessionDiscovery: true, } : { mtimeSinceMs: options?.mtimeSinceMs ?? null, forceRefresh: options?.forceRefresh === true, includeTeamSubagentSessionDiscovery: options?.includeTeamSubagentSessionDiscovery !== false, }; const requestedMembersByKey = new Map(); for (const memberName of memberNames) { const trimmed = memberName.trim(); if (!trimmed) { continue; } const key = trimmed.toLowerCase(); if (!requestedMembersByKey.has(key)) { requestedMembersByKey.set(key, trimmed); } } if (requestedMembersByKey.size === 0) { return []; } const discovery = await this.discoverProjectSessions(teamName, { forceRefresh: parsedOptions.forceRefresh, includeTeamSubagentSessionDiscovery: parsedOptions.includeTeamSubagentSessionDiscovery, }); if (!discovery) { return []; } const { projectDir, sessionIds, knownMembers, config } = discovery; const scopedKnownMembers = new Set(knownMembers); for (const memberKey of requestedMembersByKey.keys()) { scopedKnownMembers.add(memberKey); } const refs: MemberLogFileRef[] = []; const seenFilePaths = new Set(); const pushRef = (ref: MemberLogFileRef): void => { if (seenFilePaths.has(ref.filePath)) { return; } seenFilePaths.add(ref.filePath); refs.push(ref); }; const leadMemberName = config.members?.find((member) => isLeadMemberCheck(member))?.name?.trim() || 'team-lead'; const leadKey = leadMemberName.toLowerCase(); if (requestedMembersByKey.has(leadKey) && config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { const stat = await fs.stat(leadJsonl); if ( stat.isFile() && (parsedOptions.mtimeSinceMs == null || stat.mtimeMs >= parsedOptions.mtimeSinceMs) ) { pushRef({ memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName, sessionId: config.leadSessionId, filePath: leadJsonl, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, kind: 'lead_session', }); } } catch { // missing lead transcript } } const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const settled = await mapLimit(candidates, SCAN_CONCURRENCY, async (candidate) => { try { const stat = await fs.stat(candidate.filePath); if (!stat.isFile()) { return null; } if (parsedOptions.mtimeSinceMs != null && stat.mtimeMs < parsedOptions.mtimeSinceMs) { return null; } const attribution = candidate.kind === 'subagent' ? await this.getCachedSubagentAttribution( candidate.filePath, scopedKnownMembers, stat.mtimeMs ) : await this.getCachedMemberSessionAttribution( candidate.filePath, teamName, scopedKnownMembers, stat.mtimeMs ); if (!attribution) { return null; } const memberKey = attribution.detectedMember.trim().toLowerCase(); const requestedMemberName = requestedMembersByKey.get(memberKey); if (!requestedMemberName) { return null; } return { memberName: requestedMemberName, sessionId: candidate.sessionId, filePath: candidate.filePath, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, kind: candidate.kind, } satisfies MemberLogFileRef; } catch { return null; } }); for (const ref of settled) { if (ref) { pushRef(ref); } } return refs.sort((left, right) => { const byTime = right.mtimeMs - left.mtimeMs; return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath); }); } /** * Fast marker probe for task-related logs. * Prefer structured MCP/TaskUpdate markers for modern sessions. */ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const trimmedId = taskId.trim(); // CLI agents may use displayId (first 8 chars of UUID) in tool inputs. // Build regex that matches either form. const displayId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( trimmedId ) ? trimmedId.slice(0, 8).toLowerCase() : null; const idAlternation = displayId ? `(?:${trimmedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${displayId})` : trimmedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`"taskId"\\s*:\\s*"${idAlternation}"`); try { for await (const line of rl) { if (line.includes('TaskUpdate') && pattern.test(line)) { rl.close(); stream.destroy(); return true; } if (lineHasAgentTeamsTaskBoundaryToolName(line) && pattern.test(line)) { rl.close(); stream.destroy(); return true; } } } catch { // ignore read errors } rl.close(); stream.destroy(); return false; } private async discoverProjectSessions( teamName: string, options?: ProjectSessionDiscoveryOptions ): Promise { const cacheKey = buildProjectSessionDiscoveryCacheKey(teamName, options); let generation = this.discoveryGenerationByTeam.get(cacheKey) ?? 0; if (options?.forceRefresh) { const inFlight = this.discoveryInFlight.get(cacheKey); if (inFlight?.forceRefresh === true) { return inFlight.promise; } generation += 1; this.discoveryGenerationByTeam.set(cacheKey, generation); this.discoveryCache.delete(cacheKey); } else { // Check discovery cache — avoids re-reading config/dirs within rapid successive calls const cached = this.discoveryCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.result; } const inFlight = this.discoveryInFlight.get(cacheKey); if (inFlight) { return inFlight.promise; } } const promise = this.loadProjectSessionDiscovery(teamName, options, generation).finally(() => { const current = this.discoveryInFlight.get(cacheKey); if (current?.promise === promise) { this.discoveryInFlight.delete(cacheKey); } }); this.discoveryInFlight.set(cacheKey, { generation, promise, forceRefresh: options?.forceRefresh === true, }); return promise; } private async loadProjectSessionDiscovery( teamName: string, options: ProjectSessionDiscoveryOptions | undefined, generation: number ): Promise { const cacheKey = buildProjectSessionDiscoveryCacheKey(teamName, options); const context = await this.projectResolver.getContext(teamName, options); if (!context) { logger.debug(`No transcript context for team "${teamName}"`); return null; } const { config, projectDir, projectId, sessionIds } = context; const knownMembers = new Set( (config.members ?? []) .map((member) => member.name?.trim().toLowerCase()) .filter((name): name is string => Boolean(name && name.length > 0)) ); try { const metaMembers = await this.membersMetaStore.getMembers(teamName); for (const member of metaMembers) { const normalized = member.name.trim().toLowerCase(); if (normalized.length > 0) knownMembers.add(normalized); } } catch { // best-effort } try { const inboxMembers = await this.inboxReader.listInboxNames(teamName); for (const name of inboxMembers) { const normalized = name.trim().toLowerCase(); if (normalized.length > 0) knownMembers.add(normalized); } } catch { // best-effort } const discovery = { projectDir, projectId, config, sessionIds, knownMembers }; if ((this.discoveryGenerationByTeam.get(cacheKey) ?? 0) === generation) { this.discoveryCache.set(cacheKey, { result: discovery, expiresAt: Date.now() + DISCOVERY_CACHE_TTL, }); } return discovery; } private async discoverMemberFiles( teamName: string, memberName: string ): Promise<{ projectDir: string; projectId: string; config: NonNullable>>; sessionIds: string[]; knownMembers: Set; isLeadMember: boolean; } | null> { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) return null; const { config } = discovery; const leadMemberName = config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase(); return { ...discovery, isLeadMember }; } private async collectLogCandidates( projectDir: string, sessionIds: string[], config: NonNullable>> ): Promise { const candidates: LogCandidate[] = []; const leadSessionIds = new Set(); if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { leadSessionIds.add(config.leadSessionId.trim()); } if (Array.isArray(config.sessionHistory)) { for (const sessionId of config.sessionHistory) { if (typeof sessionId === 'string' && sessionId.trim().length > 0) { leadSessionIds.add(sessionId.trim()); } } } for (const sessionId of sessionIds) { const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`); if (!leadSessionIds.has(sessionId)) { try { const stat = await fs.stat(mainTranscript); if (stat.isFile()) { candidates.push({ kind: 'member_session', filePath: mainTranscript, sessionId, fileName: path.basename(mainTranscript), }); } } catch { // missing root transcript } } const subagentsDir = path.join(projectDir, sessionId, 'subagents'); let dirFiles: string[]; try { dirFiles = await fs.readdir(subagentsDir); } catch { continue; } for (const f of dirFiles) { if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact')) continue; candidates.push({ kind: 'subagent', filePath: path.join(subagentsDir, f), sessionId, fileName: f, }); } } return candidates; } private deriveSinceMs(options?: { intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }): number | null { const sinceRaw = typeof options?.since === 'string' ? options.since : null; if (sinceRaw) { const ms = Date.parse(sinceRaw); return Number.isFinite(ms) ? ms : null; } const intervals = options?.intervals; if (!Array.isArray(intervals) || intervals.length === 0) return null; let earliest = Number.POSITIVE_INFINITY; for (const i of intervals) { if (typeof i.startedAt === 'string') { const ms = Date.parse(i.startedAt); if (Number.isFinite(ms) && ms < earliest) earliest = ms; } } if (!Number.isFinite(earliest) || earliest === Number.POSITIVE_INFINITY) return null; return earliest - TASK_SINCE_GRACE_MS; } private logOverlapsIntervals( logStartMs: number, logEndMs: number, intervals: { startMs: number; endMs: number | null }[], now: number, graceMs: number ): boolean { for (const it of intervals) { const start = it.startMs - graceMs; const end = (it.endMs ?? now) + graceMs; if (logStartMs <= end && logEndMs >= start) return true; } return false; } private async fileMentionsTaskIdCached( filePath: string, teamName: string, taskId: string, assumeTeam: boolean, sinceMs: number | null ): Promise { let mtimeMs: number; try { const stat = await fs.stat(filePath); mtimeMs = stat.mtimeMs; } catch { return false; } if (sinceMs != null && mtimeMs < sinceMs - TASK_SINCE_GRACE_MS) { return false; } const cacheKey = `${filePath}:${mtimeMs}:${teamName}:${assumeTeam}`; const index = await this.getTaskMentionIndex(cacheKey, filePath, teamName, assumeTeam); return this.taskMentionIndexHasTaskId(index, taskId); } private async getTaskMentionIndex( cacheKey: string, filePath: string, teamName: string, assumeTeam: boolean ): Promise { const cached = this.taskMentionIndexCache.get(cacheKey); if (cached) return cached; const inFlight = this.taskMentionIndexInFlight.get(cacheKey); if (inFlight) return inFlight; const promise = this.buildTaskMentionIndex(filePath, teamName, assumeTeam) .then((index) => { this.taskMentionIndexCache.set(cacheKey, index); while (this.taskMentionIndexCache.size > TASK_MENTION_INDEX_CACHE_MAX) { const oldestKey = this.taskMentionIndexCache.keys().next().value; if (!oldestKey) break; this.taskMentionIndexCache.delete(oldestKey); } return index; }) .finally(() => { if (this.taskMentionIndexInFlight.get(cacheKey) === promise) { this.taskMentionIndexInFlight.delete(cacheKey); } }); this.taskMentionIndexInFlight.set(cacheKey, promise); return promise; } private taskMentionIndexHasTaskId(index: TaskMentionIndex, taskId: string): boolean { const taskIdStr = taskId.trim(); if (!taskIdStr) return false; if (index.exactTaskIds.has(taskIdStr)) return true; const taskIdDisplayForm = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskIdStr) ? taskIdStr.slice(0, 8).toLowerCase() : null; return taskIdDisplayForm !== null && index.lowerTaskIds.has(taskIdDisplayForm); } private addTaskMention(index: TaskMentionIndex, rawTaskId: string): void { const taskId = rawTaskId.trim(); if (!taskId) return; index.exactTaskIds.add(taskId); index.lowerTaskIds.add(taskId.toLowerCase()); } private async buildTaskMentionIndex( filePath: string, teamName: string, assumeTeam: boolean ): Promise { const teamLower = teamName.trim().toLowerCase(); const index: TaskMentionIndex = { exactTaskIds: new Set(), lowerTaskIds: new Set(), }; const pendingTaskIdsWithoutTeam = new Set(); const extractTaskIdFromUnknown = (raw: unknown): string | null => { if (typeof raw === 'string') return raw.trim(); if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); return null; }; const extractTeamFromInput = (input: Record): string | null => { const raw = typeof input.team_name === 'string' ? input.team_name : typeof input.teamName === 'string' ? input.teamName : typeof input.team === 'string' ? input.team : null; return typeof raw === 'string' ? raw.trim() : null; }; const matchesTeamMentionText = (text: string): boolean => { const t = text.toLowerCase(); if (!t.includes(teamLower)) return false; // Strongest signal: spawn/system prompt format includes: on team "X" (X) // Use substring checks to avoid regex word-boundary issues with kebab-case names. if (t.includes(`on team "${teamLower}"`)) return true; if (t.includes(`on team '${teamLower}'`)) return true; if (t.includes(`on team ${teamLower}`)) return true; if (t.includes(`(${teamLower})`)) return true; return false; }; const extractTeamFromProcess = (entry: Record): string | null => { const init = entry.init as Record | undefined; const process = (entry.process ?? init?.process) as Record | undefined; const team = process?.team as Record | undefined; const raw = typeof team?.teamName === 'string' ? team.teamName : typeof team?.team_name === 'string' ? team.team_name : typeof team?.name === 'string' ? team.name : null; return typeof raw === 'string' ? raw.trim() : null; }; try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let teamSeen = assumeTeam; const acceptPendingTaskIds = (): void => { if (!teamSeen || pendingTaskIdsWithoutTeam.size === 0) return; for (const pendingTaskId of pendingTaskIdsWithoutTeam) { this.addTaskMention(index, pendingTaskId); } pendingTaskIdsWithoutTeam.clear(); }; for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; try { const entry = JSON.parse(trimmed) as Record; // Team detection (for TaskUpdate without team_name): accept only if we can // confidently attribute the file to this team. if (!teamSeen) { const procTeam = extractTeamFromProcess(entry); if (procTeam?.toLowerCase() === teamLower) { teamSeen = true; } } if (!teamSeen) { const msg = entry.message as Record | undefined; const rawContent = msg?.content ?? entry.content; if (typeof rawContent === 'string' && matchesTeamMentionText(rawContent)) { teamSeen = true; } } const content = this.extractEntryContent(entry); if (!Array.isArray(content)) continue; if (!teamSeen) { // Check message text blocks for team mention (common in Solo spawn prompts) for (const block of content) { if (!block || typeof block !== 'object') continue; const b = block as Record; if ( b.type === 'text' && typeof b.text === 'string' && matchesTeamMentionText(b.text) ) { teamSeen = true; acceptPendingTaskIds(); break; } } } acceptPendingTaskIds(); for (const block of content) { if (!block || typeof block !== 'object') continue; const b = block as Record; if (b.type !== 'tool_use') continue; // Skip read-only task tools — they reference taskId but don't indicate // that this session actually WORKED on the task. Agents commonly call // task_get to check dependencies from other tasks, producing false matches. const rawToolName = typeof b.name === 'string' ? b.name : ''; const toolName = canonicalizeAgentTeamsToolName(rawToolName); if (toolName === 'task_get' || toolName === 'TaskGet') continue; const input = b.input as Record | undefined; if (!input) continue; // Deterministic structured match: any tool whose input references this task+team. const inputTeam = extractTeamFromInput(input); const rawTaskId = input.taskId ?? input.task_id; const inputTaskId = extractTaskIdFromUnknown(rawTaskId); if (inputTaskId) { // If team is present in the input, require exact match. if (inputTeam) { if (inputTeam.toLowerCase() === teamLower) { this.addTaskMention(index, inputTaskId); } } else { // Some agents use TaskUpdate without team_name (common in Solo). // Only accept when we have a separate team marker for this file. if (teamSeen) { this.addTaskMention(index, inputTaskId); } else { pendingTaskIdsWithoutTeam.add(inputTaskId); } } } } acceptPendingTaskIds(); } catch { // ignore parse errors } } rl.close(); stream.destroy(); } catch { // ignore } return index; } private extractEntryContent(entry: Record): unknown[] | null { const message = entry.message as Record | undefined; if (message && Array.isArray(message.content)) return message.content as unknown[]; if (Array.isArray(entry.content)) return entry.content as unknown[]; return null; } private async getCachedSubagentAttribution( filePath: string, knownMembers: Set, mtimeMs: number ): Promise { const cacheKey = `${filePath}:${mtimeMs}`; if (this.attributionCache.has(cacheKey)) { return this.attributionCache.get(cacheKey) ?? null; } const existing = this.attributionInFlight.get(cacheKey); if (existing) { return (await existing) as SubagentAttribution | null; } const promise = this.attributeSubagent(filePath, knownMembers).finally(() => { this.attributionInFlight.delete(cacheKey); }); this.attributionInFlight.set(cacheKey, promise); const attribution = await promise; 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 getCachedMemberSessionAttribution( filePath: string, teamName: string, knownMembers: Set, mtimeMs: number ): Promise { const cacheKey = `${filePath}:${mtimeMs}:${teamName}:member-session`; if (this.attributionCache.has(cacheKey)) { return (this.attributionCache.get(cacheKey) as RootSessionAttribution | null) ?? null; } const existing = this.attributionInFlight.get(cacheKey); if (existing) { return (await existing) as RootSessionAttribution | null; } const promise = this.attributeMemberSession(filePath, teamName, knownMembers).finally(() => { this.attributionInFlight.delete(cacheKey); }); this.attributionInFlight.set(cacheKey, promise); const attribution = await promise; 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, sessionId: string, fileName: string, targetMember: string, knownMembers: Set, precomputedAttribution?: SubagentAttribution ): Promise { const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, ''); // ── Phase 1: Attribution (first N lines) ── // Reuse pre-computed attribution when available to avoid re-reading the file. const attribution = precomputedAttribution ?? (await this.attributeSubagent(filePath, knownMembers)); if (!attribution) return null; const targetLower = targetMember.toLowerCase(); if (attribution.detectedMember.toLowerCase() !== targetLower) { return null; } // ── Phase 2: Metadata (stream entire file) ── // Now that we know the file belongs to this member, collect // accurate timestamps and message count from the full file. const metadata = await this.streamFileMetadata(filePath); const firstTimestamp = metadata.firstTimestamp ?? attribution.firstTimestamp ?? (await this.getFileMtime(filePath)); const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; const startTime = new Date(firstTimestamp); const endTime = new Date(lastTimestamp); const durationMs = endTime.getTime() - startTime.getTime(); // Check if the file might still be active (modified recently) let isOngoing = false; try { const stat = await fs.stat(filePath); const ageMs = Date.now() - stat.mtimeMs; isOngoing = ageMs < 60_000; // Active within last minute } catch { // ignore } return { kind: 'subagent', subagentId, sessionId, projectId, description: attribution.description || `Subagent ${subagentId}`, memberName: targetMember, startTime: firstTimestamp, durationMs: Math.max(0, durationMs), messageCount: metadata.messageCount, isOngoing, filePath, lastOutputPreview: metadata.lastOutputPreview ?? undefined, lastThinkingPreview: metadata.lastThinkingPreview ?? undefined, recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined, }; } private async parseMemberSessionSummary( filePath: string, projectId: string, sessionId: string, targetMember: string, teamName: string, knownMembers: Set, precomputedAttribution?: RootSessionAttribution ): Promise { const attribution = precomputedAttribution ?? (await this.attributeMemberSession(filePath, teamName, knownMembers)); if (!attribution) { return null; } if (attribution.detectedMember.toLowerCase() !== targetMember.toLowerCase()) { return null; } const metadata = await this.streamFileMetadata(filePath); const firstTimestamp = metadata.firstTimestamp ?? attribution.firstTimestamp ?? (await this.getFileMtime(filePath)); const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; const startTime = new Date(firstTimestamp); const endTime = new Date(lastTimestamp); const durationMs = endTime.getTime() - startTime.getTime(); let isOngoing = false; try { const stat = await fs.stat(filePath); isOngoing = Date.now() - stat.mtimeMs < 60_000; } catch { // ignore } return { kind: 'member_session', sessionId, projectId, description: attribution.description || `${targetMember} session`, memberName: targetMember, startTime: firstTimestamp, durationMs: Math.max(0, durationMs), messageCount: metadata.messageCount, isOngoing, filePath, lastOutputPreview: metadata.lastOutputPreview ?? undefined, lastThinkingPreview: metadata.lastThinkingPreview ?? undefined, recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined, }; } /** * Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals * and extract a human-readable description from the first user message. * Returns null if the file is a warmup session or empty. * * Collects ALL detection signals, then selects the best one by precedence * (process_team > routing_sender > teammate_id > text_mention). */ private async attributeSubagent( filePath: string, knownMembers: Set ): Promise { const lines: string[] = []; try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let count = 0; for await (const line of rl) { if (count >= ATTRIBUTION_SCAN_LINES) break; const trimmed = line.trim(); if (trimmed) { lines.push(trimmed); count++; } } rl.close(); stream.destroy(); } catch { return null; } if (lines.length === 0) return null; let description = ''; const signals: DetectionSignal[] = []; let firstTimestamp: string | null = null; for (const line of lines) { if (!firstTimestamp) { firstTimestamp = this.extractTimestampFromLine(line); } try { const msg = JSON.parse(line) as Record; const role = this.extractRole(msg); const textContent = this.extractTextContent(msg); // Skip warmup messages if (role === 'user' && textContent?.trim() === 'Warmup') { return null; } // Extract description from first user message + collect teammate_id signal if (role === 'user' && textContent) { if (textContent.trimStart().startsWith(' 0 && knownMembers.has(tmId)) { signals.push({ member: parsed[0].teammateId.trim(), source: 'teammate_id' }); } } } else if (!description) { description = textContent.slice(0, 200); } } // Collect text_mention signal (lowest reliability — exact one member name in text) const textMention = this.detectMemberFromMessage(msg, knownMembers); if (textMention) { signals.push({ member: textMention.name, source: 'text_mention' }); } // Collect routing_sender signal (high reliability — identifies the actual agent) if (msg.toolUseResult && typeof msg.toolUseResult === 'object') { const routing = (msg.toolUseResult as Record).routing as | Record | undefined; if (routing && typeof routing.sender === 'string') { const sender = routing.sender.toLowerCase(); if (knownMembers.has(sender)) { signals.push({ member: routing.sender, source: 'routing_sender' }); } } } // Collect process_team signal (highest reliability — from system init message) const init = msg.init as Record | undefined; const process = (msg.process ?? init?.process) as Record | undefined; const team = process?.team as Record | undefined; if (team && typeof team.memberName === 'string') { const memberNameLower = team.memberName.trim().toLowerCase(); if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) { signals.push({ member: team.memberName.trim(), source: 'process_team' }); } } } catch { // Skip malformed lines } // Early exit: reliable signal found and description extracted — no need to scan further. // Only process_team and routing_sender trigger this; teammate_id is unreliable (identifies // the message sender, not the agent) so we keep scanning for better signals. if ( description && signals.some((s) => s.source === 'process_team' || s.source === 'routing_sender') ) { break; } } if (signals.length === 0) return null; const best = TeamMemberLogsFinder.selectBestSignal(signals); if (!best) return null; return { detectedMember: best.member, description, firstTimestamp }; } private async attributeMemberSession( filePath: string, teamName: string, knownMembers: Set ): Promise { const lines: string[] = []; try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let count = 0; for await (const line of rl) { if (count >= ATTRIBUTION_SCAN_LINES) break; const trimmed = line.trim(); if (!trimmed) continue; lines.push(trimmed); count++; } rl.close(); stream.destroy(); } catch { return null; } if (lines.length === 0) { return null; } const normalizedTeam = teamName.trim().toLowerCase(); let detectedMember: string | null = null; let description = ''; let firstTimestamp: string | null = null; let teamMatched = false; for (const line of lines) { if (!firstTimestamp) { firstTimestamp = this.extractTimestampFromLine(line); } try { const entry = JSON.parse(line) as Record; const directTeamName = typeof entry.teamName === 'string' ? entry.teamName.trim().toLowerCase() : null; if (directTeamName === normalizedTeam) { teamMatched = true; } if (!detectedMember && typeof entry.agentName === 'string') { const normalizedMember = entry.agentName.trim().toLowerCase(); if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { detectedMember = entry.agentName.trim(); } } const process = entry.process as Record | undefined; const processTeam = process?.team as Record | undefined; if (!detectedMember && typeof processTeam?.memberName === 'string') { const normalizedMember = processTeam.memberName.trim().toLowerCase(); if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { detectedMember = processTeam.memberName.trim(); } } if (!teamMatched) { const processTeamName = typeof processTeam?.teamName === 'string' ? processTeam.teamName.trim().toLowerCase() : null; if (processTeamName === normalizedTeam) { teamMatched = true; } } const role = this.extractRole(entry); const textContent = this.extractTextContent(entry); const lowerTextContent = textContent?.toLowerCase(); if (!teamMatched && lowerTextContent?.includes(normalizedTeam)) { if ( lowerTextContent.includes(`on team "${normalizedTeam}"`) || lowerTextContent.includes(`on team '${normalizedTeam}'`) || lowerTextContent.includes(`(${normalizedTeam})`) ) { teamMatched = true; } } if (role === 'user' && textContent && !description) { const normalizedText = textContent.trim(); if ( normalizedText.length > 0 && normalizedText !== 'Warmup' && !normalizedText.startsWith('You are bootstrapping into team') && !normalizedText.startsWith('Member briefing for ') ) { description = normalizedText.slice(0, 200); } } } catch { // ignore malformed lines } if (teamMatched && detectedMember && description) { break; } } if (!teamMatched || !detectedMember) { return null; } return { detectedMember, description: description || `${detectedMember} session`, firstTimestamp, }; } /** * Select the best detection signal by precedence. * Signals are collected in file order, so find() returns the earliest occurrence * of the highest-precedence source. */ private static selectBestSignal(signals: DetectionSignal[]): DetectionSignal | null { for (const source of SIGNAL_PRECEDENCE) { const match = signals.find((s) => s.source === source); if (match) return match; } return null; } /** * Last-resort member detection from message text. * Only called when all structured signals (teammate_id, process.team, routing) failed. * Returns priority 1 (lowest) — only if exactly one known member name appears. */ private detectMemberFromMessage( msg: Record, knownMembers: Set ): { name: string; priority: number } | null { if (this.extractRole(msg) !== 'user') return null; const text = this.extractTextContent(msg); if (!text) return null; // Only attribute if exactly one known member name appears (word-boundary match). // Avoids false positives when multiple members are mentioned. const matches: string[] = []; for (const name of knownMembers) { const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i'); if (regex.test(text)) { matches.push(name); } } if (matches.length === 1) { return { name: findOriginalCase(text, matches[0]), priority: 1 }; } return null; } private extractTextContent(msg: Record): string | null { if (typeof msg.content === 'string') { return msg.content; } if (Array.isArray(msg.content)) { const textParts = (msg.content as Record[]) .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); if (textParts.length > 0) return textParts.join(' '); } // Also check message wrapper if (msg.message && typeof msg.message === 'object') { return this.extractTextContent(msg.message as Record); } return null; } private extractRole(msg: Record): string | null { if (typeof msg.role === 'string') { return msg.role; } if (msg.message && typeof msg.message === 'object') { const inner = msg.message as Record; if (typeof inner.role === 'string') { return inner.role; } } return null; } private async parseLeadSessionSummary( jsonlPath: string, projectId: string, sessionId: string, memberName: string ): Promise { try { await fs.access(jsonlPath); } catch { return null; } const metadata = await this.streamFileMetadata(jsonlPath); const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(jsonlPath)); const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; const startTime = new Date(firstTimestamp); const endTime = new Date(lastTimestamp); const durationMs = endTime.getTime() - startTime.getTime(); let isOngoing = false; try { const stat = await fs.stat(jsonlPath); const ageMs = Date.now() - stat.mtimeMs; isOngoing = ageMs < 60_000; } catch { // ignore } return { kind: 'lead_session', sessionId, projectId, description: 'Lead session', memberName, startTime: firstTimestamp, durationMs: Math.max(0, durationMs), messageCount: metadata.messageCount, isOngoing, filePath: jsonlPath, lastOutputPreview: metadata.lastOutputPreview ?? undefined, lastThinkingPreview: metadata.lastThinkingPreview ?? undefined, recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined, }; } /** * Stream entire JSONL file collecting timestamps, message count, and last assistant output. * Lightweight — uses regex to extract fields without full JSON parse. */ private async streamFileMetadata(filePath: string): Promise { let firstTimestamp: string | null = null; let lastTimestamp: string | null = null; let messageCount = 0; let lastOutputPreview: string | null = null; let lastThinkingPreview: string | null = null; const MAX_RECENT_PREVIEWS = 20; const recentPreviews: StreamedMetadata['recentPreviews'] = []; try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; messageCount++; // Fast timestamp extraction without full JSON parse. const ts = this.extractTimestampFromLine(trimmed); if (ts) { if (!firstTimestamp) firstTimestamp = ts; lastTimestamp = ts; } // Track last assistant text output (cheap regex, overwrites on each match). if (trimmed.includes('"role":"assistant"') || trimmed.includes('"role": "assistant"')) { const preview = TeamMemberLogsFinder.extractAssistantPreview(trimmed); if (preview) { lastOutputPreview = preview; if (ts) { recentPreviews.push({ text: preview, timestamp: ts, kind: 'output' }); if (recentPreviews.length > MAX_RECENT_PREVIEWS) recentPreviews.shift(); } } } // Track last thinking block (cheap regex). if (trimmed.includes('"type":"thinking"') || trimmed.includes('"type": "thinking"')) { const thinkingPreview = TeamMemberLogsFinder.extractThinkingPreview(trimmed); if (thinkingPreview) { lastThinkingPreview = thinkingPreview; if (ts) { recentPreviews.push({ text: thinkingPreview, timestamp: ts, kind: 'thinking' }); if (recentPreviews.length > MAX_RECENT_PREVIEWS) recentPreviews.shift(); } } } } rl.close(); stream.destroy(); } catch { // ignore — return whatever we collected so far } return { firstTimestamp, lastTimestamp, messageCount, lastOutputPreview, lastThinkingPreview, recentPreviews, }; } private extractTimestampFromLine(line: string): string | null { const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(line); return tsMatch?.[1] ?? null; } /** * Extract a short text preview from an assistant message line. * Looks for the first text block content via regex (avoids full JSON parse). */ private static extractAssistantPreview(line: string): string | null { // Match {"type":"text","text":"..."} blocks — allow escaped sequences const textMatch = /"type"\s*:\s*"text"[^}]*"text"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line); if (textMatch?.[1]) { const raw = textMatch[1] .replace(/\\"/g, '"') .replace(/\\n/g, ' ') .replace(/\\t/g, ' ') .replace(/\\\\/g, '\\') .replace(/\s+/g, ' ') .trim(); if (!raw) return null; return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw; } // Fallback: top-level string content — skip lines with tool_use to avoid // matching file content from Write/Edit tool inputs. if (line.includes('"tool_use"')) return null; const contentMatch = /"content"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line); if (contentMatch?.[1]) { const raw = contentMatch[1] .replace(/\\"/g, '"') .replace(/\\n/g, ' ') .replace(/\\t/g, ' ') .replace(/\\\\/g, '\\') .replace(/\s+/g, ' ') .trim(); if (!raw) return null; return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw; } return null; } /** * Extract a short preview from a thinking block line via regex. * Thinking blocks use {"type":"thinking","thinking":"..."}. */ private static extractThinkingPreview(line: string): string | null { // Allow escaped sequences (e.g. \" \n \\) inside the captured string value const match = /"thinking"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line); if (match?.[1]) { const raw = match[1] .replace(/\\"/g, '"') .replace(/\\n/g, ' ') .replace(/\\t/g, ' ') .replace(/\\\\/g, '\\') .replace(/\s+/g, ' ') .trim(); return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw; } return null; } private async probeFirstTimestamp( filePath: string, maxLines = ATTRIBUTION_SCAN_LINES ): Promise { try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let seen = 0; for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; const ts = this.extractTimestampFromLine(trimmed); if (ts) { rl.close(); stream.destroy(); return ts; } seen++; if (seen >= maxLines) break; } rl.close(); stream.destroy(); } catch { // ignore } return null; } private async getSortTime(filePath: string, timestamp: string | null): Promise { const resolvedTimestamp = timestamp ?? (await this.getFileMtime(filePath)); const sortTime = Date.parse(resolvedTimestamp); return Number.isFinite(sortTime) ? sortTime : 0; } private async getFileMtime(filePath: string): Promise { try { const stat = await fs.stat(filePath); return stat.mtime.toISOString(); } catch { return new Date().toISOString(); } } } function findOriginalCase(text: string, lowerName: string): string { const regex = new RegExp(`\\b(${escapeRegex(lowerName)})\\b`, 'i'); const match = regex.exec(text); return match ? match[1] : lowerName; } function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }