fix(team): recover root member session logs

This commit is contained in:
777genius 2026-04-15 17:38:21 +03:00
parent 03dda6b486
commit 2cfbfef3b3
9 changed files with 1066 additions and 319 deletions

View file

@ -32,6 +32,51 @@ const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
function normalizeProjectPathCandidate(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveProjectPathFromConfig(
config: Pick<TeamConfig, 'projectPath' | 'projectPathHistory' | 'members'>
): string | undefined {
const direct = normalizeProjectPathCandidate(config.projectPath);
if (direct) {
return direct;
}
const leadMemberCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd;
const leadResolved = normalizeProjectPathCandidate(leadMemberCwd);
if (leadResolved) {
return leadResolved;
}
const distinctMemberCwds = Array.from(
new Set(
(config.members ?? [])
.map((member) => normalizeProjectPathCandidate(member.cwd))
.filter((cwd): cwd is string => Boolean(cwd))
)
);
if (distinctMemberCwds.length === 1) {
return distinctMemberCwds[0];
}
if (Array.isArray(config.projectPathHistory)) {
for (let i = config.projectPathHistory.length - 1; i >= 0; i--) {
const historyValue = normalizeProjectPathCandidate(config.projectPathHistory[i]);
if (historyValue) {
return historyValue;
}
}
}
return undefined;
}
interface LaunchStateSummary { interface LaunchStateSummary {
partialLaunchFailure?: true; partialLaunchFailure?: true;
expectedMemberCount?: number; expectedMemberCount?: number;
@ -250,10 +295,7 @@ export class TeamConfigReader {
typeof config.color === 'string' && config.color.trim().length > 0 typeof config.color === 'string' && config.color.trim().length > 0
? config.color ? config.color
: undefined; : undefined;
projectPath = projectPath = resolveProjectPathFromConfig(config);
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined;
leadSessionId = leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId ? config.leadSessionId
@ -470,7 +512,8 @@ export class TeamConfigReader {
if (typeof config.name !== 'string' || config.name.trim() === '') { if (typeof config.name !== 'string' || config.name.trim() === '') {
return null; return null;
} }
return config; const resolvedProjectPath = resolveProjectPathFromConfig(config);
return resolvedProjectPath ? { ...config, projectPath: resolvedProjectPath } : config;
} catch (error) { } catch (error) {
if (error instanceof FileReadTimeoutError) { if (error instanceof FileReadTimeoutError) {
logger.warn(`[getConfig] ${error.message}`); logger.warn(`[getConfig] ${error.message}`);

View file

@ -1,4 +1,3 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
@ -14,8 +13,13 @@ import {
import { TeamConfigReader } from './TeamConfigReader'; import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types'; import type {
MemberLogSummary,
MemberSessionLogSummary,
MemberSubagentLogSummary,
} from '@shared/types';
const logger = createLogger('Service:TeamMemberLogsFinder'); const logger = createLogger('Service:TeamMemberLogsFinder');
@ -76,23 +80,32 @@ interface SubagentAttribution {
firstTimestamp: string | null; firstTimestamp: string | null;
} }
function trimTrailingSlashes(value: string): string { interface RootSessionAttribution {
let end = value.length; detectedMember: string;
while (end > 0) { description: string;
const ch = value.charCodeAt(end - 1); firstTimestamp: string | null;
// '/' or '\'
if (ch === 47 || ch === 92) {
end--;
continue;
}
break;
}
return end === value.length ? value : value.slice(0, end);
} }
type LogCandidate =
| {
kind: 'subagent';
filePath: string;
sessionId: string;
fileName: string;
}
| {
kind: 'member_session';
filePath: string;
sessionId: string;
fileName: string;
};
export class TeamMemberLogsFinder { export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>(); private readonly fileMentionsCache = new Map<string, boolean>();
private readonly attributionCache = new Map<string, SubagentAttribution | null>(); private readonly attributionCache = new Map<
string,
SubagentAttribution | RootSessionAttribution | null
>();
private readonly discoveryCache = new Map< private readonly discoveryCache = new Map<
string, string,
{ {
@ -104,7 +117,10 @@ export class TeamMemberLogsFinder {
constructor( constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
configReader
)
) {} ) {}
async findMemberLogs( async findMemberLogs(
@ -134,8 +150,8 @@ export class TeamMemberLogsFinder {
} }
// ── Collect and parallel-scan subagent files ── // ── Collect and parallel-scan subagent files ──
const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
const settled: (MemberSubagentLogSummary | null)[] = new Array(candidates.length).fill(null); const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null);
let nextIdx = 0; let nextIdx = 0;
const scanWorker = async (): Promise<void> => { const scanWorker = async (): Promise<void> => {
@ -152,17 +168,27 @@ export class TeamMemberLogsFinder {
continue; continue;
} }
} }
const summary = await this.parseSubagentSummary( const summary =
c.filePath, c.kind === 'subagent'
projectId, ? await this.parseSubagentSummary(
c.sessionId, c.filePath,
c.fileName, projectId,
memberName, c.sessionId,
knownMembers c.fileName,
); memberName,
knownMembers
)
: await this.parseMemberSessionSummary(
c.filePath,
projectId,
c.sessionId,
memberName,
teamName,
knownMembers
);
if (summary) settled[idx] = summary; if (summary) settled[idx] = summary;
} catch (err) { } catch (err) {
logger.warn(`Failed to parse subagent summary: ${c.filePath}`, err); logger.warn(`Failed to parse member log summary: ${c.filePath}`, err);
} }
} }
}; };
@ -192,7 +218,7 @@ export class TeamMemberLogsFinder {
this.discoveryCache.delete(teamName); this.discoveryCache.delete(teamName);
} }
const discovery = await this.discoverProjectSessions(teamName); const discovery = await this.discoverProjectSessions(teamName, options);
if (!discovery) { if (!discovery) {
return null; return null;
} }
@ -258,7 +284,7 @@ export class TeamMemberLogsFinder {
const tLead = performance.now(); const tLead = performance.now();
// ── Collect all subagent file candidates ── // ── Collect all subagent file candidates ──
const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
// ── Parallel scan with concurrency limit ── // ── Parallel scan with concurrency limit ──
const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null);
@ -273,20 +299,41 @@ export class TeamMemberLogsFinder {
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
continue; continue;
mentionHits++; mentionHits++;
const attribution = await this.attributeSubagent(c.filePath, knownMembers); const summary =
if (!attribution) continue; c.kind === 'subagent'
const summary = await this.parseSubagentSummary( ? await (async (): Promise<MemberLogSummary | null> => {
c.filePath, const attribution = await this.attributeSubagent(c.filePath, knownMembers);
projectId, if (!attribution) return null;
c.sessionId, return this.parseSubagentSummary(
c.fileName, c.filePath,
attribution.detectedMember, projectId,
knownMembers, c.sessionId,
attribution c.fileName,
); attribution.detectedMember,
knownMembers,
attribution
);
})()
: await (async (): Promise<MemberLogSummary | null> => {
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; if (summary) settled[idx] = summary;
} catch (err) { } catch (err) {
logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); logger.warn(`Failed to scan member log file: ${c.filePath}`, err);
} }
} }
}; };
@ -368,14 +415,18 @@ export class TeamMemberLogsFinder {
const key = const key =
log.kind === 'subagent' log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}` ? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`; : log.kind === 'member_session'
? `member:${log.sessionId}`
: `lead:${log.sessionId}`;
seen.add(key); seen.add(key);
} }
for (const log of filteredOwnerLogs) { for (const log of filteredOwnerLogs) {
const key = const key =
log.kind === 'subagent' log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}` ? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`; : log.kind === 'member_session'
? `member:${log.sessionId}`
: `lead:${log.sessionId}`;
if (!seen.has(key)) { if (!seen.has(key)) {
seen.add(key); seen.add(key);
results.push(log); results.push(log);
@ -455,16 +506,28 @@ export class TeamMemberLogsFinder {
const sinceMs = this.deriveSinceMs(options); const sinceMs = this.deriveSinceMs(options);
const { projectDir, config, sessionIds, knownMembers } = discovery; const { projectDir, config, sessionIds, knownMembers } = discovery;
const refs: { filePath: string; memberName: string; sortTime: number }[] = []; const refs: {
kind: LogCandidate['kind'] | 'lead_session';
filePath: string;
memberName: string;
sessionId: string;
sortTime: number;
}[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const leadMemberName = const leadMemberName =
config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead';
const pushRef = (filePath: string, memberName: string, sortTime = 0): void => { const pushRef = (
const key = `${memberName.toLowerCase()}:${filePath}`; 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; if (seen.has(key)) return;
seen.add(key); seen.add(key);
refs.push({ filePath, memberName, sortTime }); refs.push({ kind, filePath, memberName, sessionId, sortTime });
}; };
if (config.leadSessionId) { if (config.leadSessionId) {
@ -473,7 +536,13 @@ export class TeamMemberLogsFinder {
await fs.access(leadJsonl); await fs.access(leadJsonl);
if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) { if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) {
const firstTimestamp = await this.probeFirstTimestamp(leadJsonl); const firstTimestamp = await this.probeFirstTimestamp(leadJsonl);
pushRef(leadJsonl, leadMemberName, await this.getSortTime(leadJsonl, firstTimestamp)); pushRef(
leadJsonl,
leadMemberName,
await this.getSortTime(leadJsonl, firstTimestamp),
'lead_session',
config.leadSessionId
);
} }
} catch { } catch {
// file missing or unreadable // file missing or unreadable
@ -482,7 +551,7 @@ export class TeamMemberLogsFinder {
const tLead = performance.now(); const tLead = performance.now();
// ── Collect all subagent file candidates ── // ── Collect all subagent file candidates ──
const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
// ── Parallel scan with concurrency limit ── // ── Parallel scan with concurrency limit ──
let nextIdx = 0; let nextIdx = 0;
@ -496,15 +565,20 @@ export class TeamMemberLogsFinder {
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
continue; continue;
mentionHits++; mentionHits++;
const attribution = await this.attributeSubagent(c.filePath, knownMembers); const attribution =
c.kind === 'subagent'
? await this.attributeSubagent(c.filePath, knownMembers)
: await this.attributeMemberSession(c.filePath, teamName, knownMembers);
if (!attribution) continue; if (!attribution) continue;
pushRef( pushRef(
c.filePath, c.filePath,
attribution.detectedMember, attribution.detectedMember,
await this.getSortTime(c.filePath, attribution.firstTimestamp) await this.getSortTime(c.filePath, attribution.firstTimestamp),
c.kind,
c.sessionId
); );
} catch (err) { } catch (err) {
logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); logger.warn(`Failed to scan member log file: ${c.filePath}`, err);
} }
} }
}; };
@ -585,7 +659,11 @@ export class TeamMemberLogsFinder {
pushRef( pushRef(
log.filePath, log.filePath,
log.memberName ?? normalizedOwner, log.memberName ?? normalizedOwner,
Number.isFinite(new Date(log.startTime).getTime()) ? new Date(log.startTime).getTime() : 0 Number.isFinite(new Date(log.startTime).getTime())
? new Date(log.startTime).getTime()
: 0,
log.kind === 'lead_session' ? 'lead_session' : log.kind,
log.sessionId
); );
} }
} }
@ -595,22 +673,28 @@ export class TeamMemberLogsFinder {
{ {
const refsByKey = new Map<string, (typeof refs)[0]>(); const refsByKey = new Map<string, (typeof refs)[0]>();
const leadRefs: (typeof refs)[0][] = []; const leadRefs: (typeof refs)[0][] = [];
const memberSessionRefsByKey = new Map<string, (typeof refs)[0]>();
for (const ref of refs) { for (const ref of refs) {
if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) { if (ref.kind === 'lead_session') {
leadRefs.push(ref); leadRefs.push(ref);
continue; continue;
} }
const parts = ref.filePath.split(path.sep); if (ref.kind === 'member_session') {
const subagentsIdx = parts.lastIndexOf('subagents'); const key = `member:${ref.sessionId}:${ref.memberName.toLowerCase()}`;
const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : ''; const existing = memberSessionRefsByKey.get(key);
const key = `${sessionId}:${ref.memberName.toLowerCase()}`; if (!existing || ref.sortTime > existing.sortTime) {
memberSessionRefsByKey.set(key, ref);
}
continue;
}
const key = `${ref.sessionId}:${ref.memberName.toLowerCase()}`;
const existing = refsByKey.get(key); const existing = refsByKey.get(key);
if (!existing || ref.sortTime > existing.sortTime) { if (!existing || ref.sortTime > existing.sortTime) {
refsByKey.set(key, ref); refsByKey.set(key, ref);
} }
} }
refs.length = 0; refs.length = 0;
refs.push(...leadRefs, ...refsByKey.values()); refs.push(...leadRefs, ...memberSessionRefsByKey.values(), ...refsByKey.values());
} }
const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime);
@ -650,43 +734,32 @@ export class TeamMemberLogsFinder {
} }
} }
for (const sessionId of sessionIds) { const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
const subagentsDir = path.join(projectDir, sessionId, 'subagents'); for (const candidate of candidates) {
let mtimeMs = 0;
let files: string[];
try { try {
files = await fs.readdir(subagentsDir); mtimeMs = (await fs.stat(candidate.filePath)).mtimeMs;
} catch { } catch {
continue; continue;
} }
const attribution =
for (const file of files) { candidate.kind === 'subagent'
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; ? await this.getCachedSubagentAttribution(candidate.filePath, knownMembers, mtimeMs)
if (file.startsWith('agent-acompact')) continue; : await this.getCachedMemberSessionAttribution(
candidate.filePath,
const filePath = path.join(subagentsDir, file); teamName,
// Quick attribution check — only Phase 1 (no full-file streaming) knownMembers,
let mtimeMs = 0; mtimeMs
try { );
mtimeMs = (await fs.stat(filePath)).mtimeMs; if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
} catch { paths.push(candidate.filePath);
continue;
}
const attribution = await this.getCachedSubagentAttribution(
filePath,
knownMembers,
mtimeMs
);
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
paths.push(filePath);
}
} }
} }
return paths; return paths;
} }
async listAttributedSubagentFiles( async listAttributedMemberFiles(
teamName: string teamName: string
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> {
const discovery = await this.discoverProjectSessions(teamName); const discovery = await this.discoverProjectSessions(teamName);
@ -697,13 +770,7 @@ export class TeamMemberLogsFinder {
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim() ? config.leadSessionId.trim()
: null; : null;
// Live teammate tool tracking should follow the current team run, not historical const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
// lead sessions kept in sessionHistory or lingering on disk.
const candidateSessionIds =
currentLeadSessionId && sessionIds.includes(currentLeadSessionId)
? [currentLeadSessionId]
: sessionIds;
const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds);
const results: { const results: {
memberName: string; memberName: string;
sessionId: string; sessionId: string;
@ -714,12 +781,27 @@ export class TeamMemberLogsFinder {
const settled = await Promise.all( const settled = await Promise.all(
candidates.map(async (candidate) => { candidates.map(async (candidate) => {
try { try {
if (
candidate.kind === 'subagent' &&
currentLeadSessionId &&
candidate.sessionId !== currentLeadSessionId
) {
return null;
}
const stat = await fs.stat(candidate.filePath); const stat = await fs.stat(candidate.filePath);
const attribution = await this.getCachedSubagentAttribution( const attribution =
candidate.filePath, candidate.kind === 'subagent'
knownMembers, ? await this.getCachedSubagentAttribution(
stat.mtimeMs candidate.filePath,
); knownMembers,
stat.mtimeMs
)
: await this.getCachedMemberSessionAttribution(
candidate.filePath,
teamName,
knownMembers,
stat.mtimeMs
);
if (!attribution) return null; if (!attribution) return null;
return { return {
memberName: attribution.detectedMember, memberName: attribution.detectedMember,
@ -737,7 +819,34 @@ export class TeamMemberLogsFinder {
if (item) results.push(item); if (item) results.push(item);
} }
return results; 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);
} }
/** /**
@ -783,97 +892,32 @@ export class TeamMemberLogsFinder {
return false; return false;
} }
private async discoverProjectSessions(teamName: string): Promise<{ private async discoverProjectSessions(
teamName: string,
options?: { forceRefresh?: boolean }
): Promise<{
projectDir: string; projectDir: string;
projectId: string; projectId: string;
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>; config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
sessionIds: string[]; sessionIds: string[];
knownMembers: Set<string>; knownMembers: Set<string>;
} | null> { } | null> {
// Check discovery cache — avoids re-reading config/dirs within rapid successive calls if (options?.forceRefresh) {
const cached = this.discoveryCache.get(teamName); this.discoveryCache.delete(teamName);
if (cached && cached.expiresAt > Date.now()) { } else {
return cached.result; // Check discovery cache — avoids re-reading config/dirs within rapid successive calls
const cached = this.discoveryCache.get(teamName);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
} }
const config = await this.configReader.getConfig(teamName); const context = await this.projectResolver.getContext(teamName, options);
if (!config?.projectPath) { if (!context) {
logger.debug(`No projectPath for team "${teamName}"`); logger.debug(`No transcript context for team "${teamName}"`);
return null; return null;
} }
const { config, projectDir, projectId, sessionIds } = context;
const normalizedProjectPath = trimTrailingSlashes(config.projectPath);
let projectId = encodePath(normalizedProjectPath);
let baseDir = extractBaseDir(projectId);
let projectDir = path.join(getProjectsBasePath(), baseDir);
// If the encoded directory doesn't exist (symlink/cwd mismatch), fall back to locating
// the project directory by leadSessionId which is unique and reliable.
try {
const stat = await fs.stat(projectDir);
if (!stat.isDirectory()) {
throw new Error('not a directory');
}
} catch {
const leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
if (leadSessionId) {
const projectsBase = getProjectsBasePath();
try {
const entries = await fs.readdir(projectsBase, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const candidateDir = path.join(projectsBase, entry.name);
const leadPath = path.join(candidateDir, `${leadSessionId}.jsonl`);
try {
await fs.access(leadPath);
projectDir = candidateDir;
projectId = entry.name;
baseDir = entry.name;
break;
} catch {
// not this project
}
}
} catch {
// ignore
}
}
}
const knownSessionIds = new Set<string>();
if (config.leadSessionId) {
knownSessionIds.add(config.leadSessionId);
}
if (Array.isArray(config.sessionHistory)) {
for (const sid of config.sessionHistory) {
if (typeof sid === 'string' && sid.trim().length > 0) {
knownSessionIds.add(sid.trim());
}
}
}
const discoveredSessionIds = await this.listSessionDirs(projectDir);
let sessionIds: string[];
if (knownSessionIds.size > 0) {
const verified: string[] = [];
for (const sid of knownSessionIds) {
const sidDir = path.join(projectDir, sid);
try {
const stat = await fs.stat(sidDir);
if (stat.isDirectory()) verified.push(sid);
} catch {
// dir doesn't exist
}
}
// Prefer config-backed sessions first, but also include any live session dirs that have
// appeared on disk and are not yet reflected in config/sessionHistory.
sessionIds = Array.from(new Set([...verified, ...discoveredSessionIds]));
} else {
sessionIds = discoveredSessionIds;
}
const knownMembers = new Set<string>( const knownMembers = new Set<string>(
(config.members ?? []) (config.members ?? [])
@ -927,16 +971,42 @@ export class TeamMemberLogsFinder {
return { ...discovery, isLeadMember }; return { ...discovery, isLeadMember };
} }
/** private async collectLogCandidates(
* Collect all subagent JSONL file candidates across session directories.
* Filters out non-agent files and compact files (agent-acompact*).
*/
private async collectSubagentCandidates(
projectDir: string, projectDir: string,
sessionIds: string[] sessionIds: string[],
): Promise<{ filePath: string; sessionId: string; fileName: string }[]> { config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>
const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; ): Promise<LogCandidate[]> {
const candidates: LogCandidate[] = [];
const leadSessionIds = new Set<string>();
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) { 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'); const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let dirFiles: string[]; let dirFiles: string[];
try { try {
@ -947,7 +1017,12 @@ export class TeamMemberLogsFinder {
for (const f of dirFiles) { for (const f of dirFiles) {
if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact')) if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact'))
continue; continue;
candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f }); candidates.push({
kind: 'subagent',
filePath: path.join(subagentsDir, f),
sessionId,
fileName: f,
});
} }
} }
return candidates; return candidates;
@ -1201,16 +1276,6 @@ export class TeamMemberLogsFinder {
return null; return null;
} }
private async listSessionDirs(projectDir: string): Promise<string[]> {
try {
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
return dirEntries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
logger.debug(`Cannot read project dir: ${projectDir}`);
return [];
}
}
private async getCachedSubagentAttribution( private async getCachedSubagentAttribution(
filePath: string, filePath: string,
knownMembers: Set<string>, knownMembers: Set<string>,
@ -1229,6 +1294,25 @@ export class TeamMemberLogsFinder {
return attribution; return attribution;
} }
private async getCachedMemberSessionAttribution(
filePath: string,
teamName: string,
knownMembers: Set<string>,
mtimeMs: number
): Promise<RootSessionAttribution | null> {
const cacheKey = `${filePath}:${mtimeMs}:${teamName}:member-session`;
if (this.attributionCache.has(cacheKey)) {
return (this.attributionCache.get(cacheKey) as RootSessionAttribution | null) ?? null;
}
const attribution = await this.attributeMemberSession(filePath, teamName, knownMembers);
this.attributionCache.set(cacheKey, attribution);
if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) {
const oldestKey = this.attributionCache.keys().next().value;
if (oldestKey) this.attributionCache.delete(oldestKey);
}
return attribution;
}
private async parseSubagentSummary( private async parseSubagentSummary(
filePath: string, filePath: string,
projectId: string, projectId: string,
@ -1292,6 +1376,60 @@ export class TeamMemberLogsFinder {
}; };
} }
private async parseMemberSessionSummary(
filePath: string,
projectId: string,
sessionId: string,
targetMember: string,
teamName: string,
knownMembers: Set<string>,
precomputedAttribution?: RootSessionAttribution
): Promise<MemberSessionLogSummary | null> {
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 * Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals
* and extract a human-readable description from the first user message. * and extract a human-readable description from the first user message.
@ -1421,6 +1559,122 @@ export class TeamMemberLogsFinder {
return { detectedMember: best.member, description, firstTimestamp }; return { detectedMember: best.member, description, firstTimestamp };
} }
private async attributeMemberSession(
filePath: string,
teamName: string,
knownMembers: Set<string>
): Promise<RootSessionAttribution | null> {
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<string, unknown>;
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<string, unknown> | undefined;
const processTeam = process?.team as Record<string, unknown> | 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);
if (!teamMatched && textContent && textContent.toLowerCase().includes(normalizedTeam)) {
if (
textContent.toLowerCase().includes(`on team "${normalizedTeam}"`) ||
textContent.toLowerCase().includes(`on team '${normalizedTeam}'`) ||
textContent.toLowerCase().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. * Select the best detection signal by precedence.
* Signals are collected in file order, so find() returns the earliest occurrence * Signals are collected in file order, so find() returns the earliest occurrence

View file

@ -0,0 +1,297 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { createReadStream, type Dirent } from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as readline from 'readline';
import { TeamConfigReader } from './TeamConfigReader';
import type { TeamConfig } from '@shared/types';
const logger = createLogger('Service:TeamTranscriptProjectResolver');
const SESSION_DISCOVERY_CACHE_TTL = 30_000;
const TEAM_AFFINITY_SCAN_LINES = 40;
const ROOT_DISCOVERY_CONCURRENCY = 12;
function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0) {
const ch = value.charCodeAt(end - 1);
if (ch === 47 || ch === 92) {
end -= 1;
continue;
}
break;
}
return end === value.length ? value : value.slice(0, end);
}
function isSessionDirectoryName(name: string): boolean {
return name !== 'memory' && !name.startsWith('.');
}
function extractTextContent(entry: Record<string, unknown>): string | null {
if (typeof entry.content === 'string') {
return entry.content;
}
if (Array.isArray(entry.content)) {
const textParts = (entry.content as Record<string, unknown>[])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string);
if (textParts.length > 0) {
return textParts.join(' ');
}
}
if (entry.message && typeof entry.message === 'object') {
return extractTextContent(entry.message as Record<string, unknown>);
}
return null;
}
function extractDirectTeamName(entry: Record<string, unknown>): string | null {
if (typeof entry.teamName === 'string') {
return entry.teamName.trim().toLowerCase();
}
const process = entry.process as Record<string, unknown> | undefined;
const processTeam = process?.team as Record<string, unknown> | undefined;
if (typeof processTeam?.teamName === 'string') {
return processTeam.teamName.trim().toLowerCase();
}
return null;
}
function lineMentionsTeam(text: string, teamName: string): boolean {
const normalizedText = text.trim().toLowerCase();
const normalizedTeam = teamName.trim().toLowerCase();
if (!normalizedText.includes(normalizedTeam)) {
return false;
}
return (
normalizedText.includes(`on team "${normalizedTeam}"`) ||
normalizedText.includes(`on team '${normalizedTeam}'`) ||
normalizedText.includes(`team "${normalizedTeam}"`) ||
normalizedText.includes(`team '${normalizedTeam}'`) ||
normalizedText.includes(`(${normalizedTeam})`)
);
}
function collectKnownSessionIds(config: TeamConfig): string[] {
const knownSessionIds = new Set<string>();
const push = (value: unknown): void => {
if (typeof value !== 'string') {
return;
}
const trimmed = value.trim();
if (trimmed.length > 0) {
knownSessionIds.add(trimmed);
}
};
push(config.leadSessionId);
if (Array.isArray(config.sessionHistory)) {
for (const sessionId of config.sessionHistory) {
push(sessionId);
}
}
return [...knownSessionIds];
}
export interface TeamTranscriptProjectContext {
projectDir: string;
projectId: string;
config: TeamConfig;
sessionIds: string[];
}
export class TeamTranscriptProjectResolver {
private readonly contextCache = new Map<
string,
{ value: TeamTranscriptProjectContext; expiresAt: number }
>();
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
async getContext(
teamName: string,
options?: { forceRefresh?: boolean }
): Promise<TeamTranscriptProjectContext | null> {
if (options?.forceRefresh) {
this.contextCache.delete(teamName);
}
const cached = this.contextCache.get(teamName);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) {
return null;
}
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
const value = { projectDir, projectId, config, sessionIds };
this.contextCache.set(teamName, {
value,
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
});
return value;
}
private async resolveProjectDirectory(
config: TeamConfig
): Promise<{ projectDir: string; projectId: string }> {
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
let projectId = encodePath(normalizedProjectPath);
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
try {
const stat = await fs.stat(projectDir);
if (!stat.isDirectory()) {
throw new Error('not a directory');
}
return { projectDir, projectId };
} catch {
const leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
if (!leadSessionId) {
return { projectDir, projectId };
}
try {
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
for (const entry of projectEntries) {
if (!entry.isDirectory()) continue;
const candidateDir = path.join(getProjectsBasePath(), entry.name);
try {
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
projectDir = candidateDir;
projectId = entry.name;
break;
} catch {
// not this project
}
}
} catch {
// best-effort fallback
}
}
return { projectDir, projectId };
}
private async discoverSessionIds(
teamName: string,
projectDir: string,
config: TeamConfig
): Promise<string[]> {
const knownSessionIds = collectKnownSessionIds(config);
const [teamRootSessionIds, sessionDirIds] = await Promise.all([
this.listTeamRootSessionIds(projectDir, teamName),
this.listSessionDirIds(projectDir),
]);
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
(left, right) => left.localeCompare(right)
);
}
private async listSessionDirIds(projectDir: string): Promise<string[]> {
try {
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
return dirEntries
.filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name))
.map((entry) => entry.name);
} catch {
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
return [];
}
}
private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise<string[]> {
let dirEntries: Dirent[];
try {
dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
} catch {
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
return [];
}
const rootJsonlEntries = dirEntries.filter(
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
);
const discovered = new Set<string>();
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < rootJsonlEntries.length) {
const index = nextIndex++;
const entry = rootJsonlEntries[index];
const filePath = path.join(projectDir, entry.name);
if (!(await this.fileBelongsToTeam(filePath, teamName))) {
continue;
}
discovered.add(entry.name.slice(0, -'.jsonl'.length));
}
};
await Promise.all(
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () =>
worker()
)
);
return [...discovered];
}
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
const normalizedTeam = teamName.trim().toLowerCase();
try {
let inspected = 0;
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
inspected += 1;
try {
const entry = JSON.parse(trimmed) as Record<string, unknown>;
const directTeamName = extractDirectTeamName(entry);
if (directTeamName === normalizedTeam) {
return true;
}
const textContent = extractTextContent(entry);
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
return true;
}
} catch {
// ignore malformed head lines
}
if (inspected >= TEAM_AFFINITY_SCAN_LINES) {
break;
}
}
} catch {
return false;
} finally {
rl.close();
stream.destroy();
}
return false;
}
}

View file

@ -145,7 +145,7 @@ export class TeammateToolTracker {
const state = this.stateByTeam.get(teamName); const state = this.stateByTeam.get(teamName);
if (!state?.enabled || state.epoch !== expectedEpoch) return; if (!state?.enabled || state.epoch !== expectedEpoch) return;
const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); const attributedFiles = await this.logsFinder.listAttributedMemberFiles(teamName);
const currentState = this.stateByTeam.get(teamName); const currentState = this.stateByTeam.get(teamName);
if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return;

View file

@ -1,27 +1,10 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { TeamConfigReader } from '../../TeamConfigReader'; import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver';
import type { TeamConfig } from '@shared/types'; import type { TeamConfig } from '@shared/types';
const logger = createLogger('Service:TeamTranscriptSourceLocator');
function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0) {
const ch = value.charCodeAt(end - 1);
if (ch === 47 || ch === 92) {
end -= 1;
continue;
}
break;
}
return end === value.length ? value : value.slice(0, end);
}
export interface TeamTranscriptSourceContext { export interface TeamTranscriptSourceContext {
projectDir: string; projectDir: string;
projectId: string; projectId: string;
@ -31,50 +14,17 @@ export interface TeamTranscriptSourceContext {
} }
export class TeamTranscriptSourceLocator { export class TeamTranscriptSourceLocator {
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} constructor(
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver()
) {}
async getContext(teamName: string): Promise<TeamTranscriptSourceContext | null> { async getContext(teamName: string): Promise<TeamTranscriptSourceContext | null> {
const config = await this.configReader.getConfig(teamName); const context = await this.projectResolver.getContext(teamName);
if (!config?.projectPath) { if (!context) {
return null; return null;
} }
const normalizedProjectPath = trimTrailingSlashes(config.projectPath); const { projectDir, projectId, config, sessionIds } = context;
let projectId = encodePath(normalizedProjectPath);
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
try {
const stat = await fs.stat(projectDir);
if (!stat.isDirectory()) {
throw new Error('not a directory');
}
} catch {
const leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
if (leadSessionId) {
try {
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
for (const entry of projectEntries) {
if (!entry.isDirectory()) continue;
const candidateDir = path.join(getProjectsBasePath(), entry.name);
try {
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
projectDir = candidateDir;
projectId = entry.name;
break;
} catch {
// not this project
}
}
} catch {
// best-effort fallback
}
}
}
const sessionIds = await this.discoverSessionIds(projectDir, config);
const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds);
return { projectDir, projectId, config, sessionIds, transcriptFiles }; return { projectDir, projectId, config, sessionIds, transcriptFiles };
} }
@ -83,51 +33,6 @@ export class TeamTranscriptSourceLocator {
const context = await this.getContext(teamName); const context = await this.getContext(teamName);
return context?.transcriptFiles ?? []; return context?.transcriptFiles ?? [];
} }
private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise<string[]> {
const knownSessionIds = new Set<string>();
if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) {
knownSessionIds.add(config.leadSessionId.trim());
}
if (Array.isArray(config.sessionHistory)) {
for (const sessionId of config.sessionHistory) {
if (typeof sessionId === 'string' && sessionId.trim().length > 0) {
knownSessionIds.add(sessionId.trim());
}
}
}
let discoveredSessionDirs: string[] = [];
try {
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
discoveredSessionDirs = dirEntries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
}
if (knownSessionIds.size === 0) {
return discoveredSessionDirs.sort();
}
const verifiedSessionIds: string[] = [];
for (const sessionId of knownSessionIds) {
try {
const stat = await fs.stat(path.join(projectDir, sessionId));
if (stat.isDirectory()) {
verifiedSessionIds.push(sessionId);
}
} catch {
// ignore stale config session
}
}
return Array.from(
new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs])
).sort();
}
private async listTranscriptFilesForSessions( private async listTranscriptFilesForSessions(
projectDir: string, projectDir: string,
sessionIds: string[] sessionIds: string[]
@ -160,6 +65,6 @@ export class TeamTranscriptSourceLocator {
} }
} }
return [...transcriptFiles].sort(); return [...transcriptFiles].sort((left, right) => left.localeCompare(right));
} }
} }

View file

@ -183,9 +183,13 @@ export const MemberLogsTab = ({
}, []); }, []);
const getRowId = useCallback((log: MemberLogSummary): string => { const getRowId = useCallback((log: MemberLogSummary): string => {
return log.kind === 'subagent' if (log.kind === 'subagent') {
? `subagent:${log.sessionId}:${log.subagentId}` return `subagent:${log.sessionId}:${log.subagentId}`;
: `lead:${log.sessionId}`; }
if (log.kind === 'member_session') {
return `member:${log.sessionId}`;
}
return `lead:${log.sessionId}`;
}, []); }, []);
const sortedLogs = useMemo(() => { const sortedLogs = useMemo(() => {
@ -250,7 +254,9 @@ export const MemberLogsTab = ({
if (!shouldShowPreview) return null; if (!shouldShowPreview) return null;
if (showSubagentPreview) { if (showSubagentPreview) {
const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); const candidates = sortedLogs.filter(
(l) => l.kind === 'subagent' || l.kind === 'member_session'
);
if (candidates.length === 0) return null; if (candidates.length === 0) return null;
if (taskOwner) { if (taskOwner) {
@ -515,6 +521,10 @@ export const MemberLogsTab = ({
); );
return d?.chunks ?? null; return d?.chunks ?? null;
} }
if (log.kind === 'member_session') {
const d = await api.getSessionDetail(log.projectId, log.sessionId, options);
return d ? asEnhancedChunkArray(d.chunks) : null;
}
const d = await api.getSessionDetail(log.projectId, log.sessionId, options); const d = await api.getSessionDetail(log.projectId, log.sessionId, options);
return d ? asEnhancedChunkArray(d.chunks) : null; return d ? asEnhancedChunkArray(d.chunks) : null;
}, },
@ -766,7 +776,7 @@ const LogCard = ({
<span className="truncate text-[var(--color-text)]" title={log.description}> <span className="truncate text-[var(--color-text)]" title={log.description}>
{log.description} {log.description}
</span> </span>
{log.kind === 'lead_session' && ( {(log.kind === 'lead_session' || log.kind === 'member_session') && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
@ -777,8 +787,9 @@ const LogCard = ({
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-center"> <TooltipContent side="top" className="max-w-[240px] text-center">
Full team lead session logs useful for global orchestration context, not {log.kind === 'lead_session'
specific to this agent ? 'Full team lead session logs - useful for global orchestration context, not specific to this agent'
: 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file'}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@ -838,7 +849,11 @@ const LogCard = ({
<div className="w-full min-w-0"> <div className="w-full min-w-0">
<MemberExecutionLog <MemberExecutionLog
chunks={detailChunks} chunks={detailChunks}
memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined} memberName={
log.kind === 'lead_session' || log.kind === 'member_session'
? (log.memberName ?? undefined)
: undefined
}
/> />
</div> </div>
)} )}

View file

@ -1040,7 +1040,7 @@ export interface MemberSubagentSummary {
isOngoing: boolean; isOngoing: boolean;
} }
export type MemberLogKind = 'subagent' | 'lead_session'; export type MemberLogKind = 'subagent' | 'lead_session' | 'member_session';
export interface MemberLogSummaryBase { export interface MemberLogSummaryBase {
kind: MemberLogKind; kind: MemberLogKind;
@ -1071,7 +1071,14 @@ export interface MemberLeadSessionLogSummary extends MemberLogSummaryBase {
kind: 'lead_session'; kind: 'lead_session';
} }
export type MemberLogSummary = MemberSubagentLogSummary | MemberLeadSessionLogSummary; export interface MemberSessionLogSummary extends MemberLogSummaryBase {
kind: 'member_session';
}
export type MemberLogSummary =
| MemberSubagentLogSummary
| MemberLeadSessionLogSummary
| MemberSessionLogSummary;
export interface FileLineStats { export interface FileLineStats {
added: number; added: number;

View file

@ -97,6 +97,118 @@ describe('TeamMemberLogsFinder', () => {
expect(lead?.projectId).toBe(projectId); expect(lead?.projectId).toBe(projectId);
}); });
it('returns root member sessions when config.projectPath is missing but member cwd is present', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'signal-ops-root';
const projectPath = '/Users/test/signal-ops-root';
const projectId = '-Users-test-signal-ops-root';
const leadSessionId = 'lead-root';
const memberSessionId = 'member-bob-root';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify(
{
name: teamName,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath },
{ name: 'bob', agentType: 'general-purpose', cwd: projectPath },
],
},
null,
2
),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(projectRoot, { recursive: true });
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
JSON.stringify({
timestamp: '2026-04-15T14:02:00.000Z',
type: 'user',
teamName,
agentName: 'team-lead',
message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` },
}) + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, `${memberSessionId}.jsonl`),
[
JSON.stringify({
timestamp: '2026-04-15T14:02:01.000Z',
type: 'user',
teamName,
agentName: 'bob',
message: {
role: 'user',
content: `You are bootstrapping into team "${teamName}" as member "bob".`,
},
}),
JSON.stringify({
timestamp: '2026-04-15T14:02:05.000Z',
type: 'assistant',
teamName,
agentName: 'bob',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'call-task-start',
name: 'mcp__agent-teams__task_start',
input: {
teamName,
taskId: 'task-root-1',
},
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
const bobLogs = await finder.findMemberLogs(teamName, 'bob');
const taskLogs = await finder.findLogsForTask(teamName, 'task-root-1');
const attributedFiles = await finder.listAttributedMemberFiles(teamName);
expect(bobLogs).toHaveLength(1);
expect(bobLogs[0]?.kind).toBe('member_session');
if (bobLogs[0]?.kind === 'member_session') {
expect(bobLogs[0].sessionId).toBe(memberSessionId);
expect(bobLogs[0].projectId).toBe(projectId);
expect(bobLogs[0].memberName?.toLowerCase()).toBe('bob');
expect(bobLogs[0].filePath).toBe(path.join(projectRoot, `${memberSessionId}.jsonl`));
}
expect(
taskLogs.some(
(log) =>
log.kind === 'member_session' &&
log.sessionId === memberSessionId &&
log.memberName?.toLowerCase() === 'bob'
)
).toBe(true);
expect(attributedFiles).toEqual([
{
memberName: 'bob',
sessionId: memberSessionId,
filePath: path.join(projectRoot, `${memberSessionId}.jsonl`),
mtimeMs: expect.any(Number),
},
]);
});
it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
@ -841,7 +953,7 @@ describe('TeamMemberLogsFinder', () => {
{ {
type: 'tool_use', type: 'tool_use',
name: 'Bash', name: 'Bash',
input: { command: 'node \"teamctl.js\" --team demo task start task-42' }, input: { command: 'node "teamctl.js" --team demo task start task-42' },
}, },
], ],
}, },

View file

@ -0,0 +1,114 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import { TeamTranscriptSourceLocator } from '../../../../src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
describe('TeamTranscriptSourceLocator', () => {
let tmpDir: string | null = null;
afterEach(async () => {
setClaudeBasePathOverride(null);
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('recovers projectPath from member cwd and includes only team-related root sessions', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-transcripts-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'signal-ops-test';
const projectPath = '/Users/test/signal-ops';
const projectId = '-Users-test-signal-ops';
const leadSessionId = 'lead-session';
const memberSessionId = 'member-bob';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify(
{
name: teamName,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath },
{ name: 'bob', agentType: 'general-purpose', cwd: projectPath },
],
},
null,
2
),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
JSON.stringify({
timestamp: '2026-04-15T14:02:00.000Z',
type: 'user',
teamName,
agentName: 'team-lead',
message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` },
}) + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, `${memberSessionId}.jsonl`),
JSON.stringify({
timestamp: '2026-04-15T14:02:01.000Z',
type: 'user',
teamName,
agentName: 'bob',
message: {
role: 'user',
content: `You are bootstrapping into team "${teamName}" as member "bob".`,
},
}) + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, 'unrelated-session.jsonl'),
JSON.stringify({
timestamp: '2026-04-15T14:02:02.000Z',
type: 'user',
message: { role: 'user', content: 'Unrelated solo session' },
}) + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'),
JSON.stringify({
timestamp: '2026-04-15T14:02:03.000Z',
type: 'user',
message: { role: 'user', content: `You are bob, a developer on team "${teamName}".` },
}) + '\n',
'utf8'
);
const locator = new TeamTranscriptSourceLocator();
const context = await locator.getContext(teamName);
const transcriptFiles = await locator.listTranscriptFiles(teamName);
expect(context).not.toBeNull();
expect(context?.projectId).toBe(projectId);
expect(context?.config.projectPath).toBe(projectPath);
expect(context?.sessionIds).toEqual(expect.arrayContaining([leadSessionId, memberSessionId]));
expect(context?.sessionIds).not.toContain('unrelated-session');
expect(transcriptFiles).toEqual(
expect.arrayContaining([
path.join(projectRoot, `${leadSessionId}.jsonl`),
path.join(projectRoot, `${memberSessionId}.jsonl`),
path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'),
])
);
expect(transcriptFiles).not.toContain(path.join(projectRoot, 'unrelated-session.jsonl'));
});
});