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 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 {
partialLaunchFailure?: true;
expectedMemberCount?: number;
@ -250,10 +295,7 @@ export class TeamConfigReader {
typeof config.color === 'string' && config.color.trim().length > 0
? config.color
: undefined;
projectPath =
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined;
projectPath = resolveProjectPathFromConfig(config);
leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId
@ -470,7 +512,8 @@ export class TeamConfigReader {
if (typeof config.name !== 'string' || config.name.trim() === '') {
return null;
}
return config;
const resolvedProjectPath = resolveProjectPathFromConfig(config);
return resolvedProjectPath ? { ...config, projectPath: resolvedProjectPath } : config;
} catch (error) {
if (error instanceof FileReadTimeoutError) {
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 { createLogger } from '@shared/utils/logger';
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
@ -14,8 +13,13 @@ import {
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
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');
@ -76,23 +80,32 @@ interface SubagentAttribution {
firstTimestamp: string | null;
}
function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0) {
const ch = value.charCodeAt(end - 1);
// '/' or '\'
if (ch === 47 || ch === 92) {
end--;
continue;
}
break;
}
return end === value.length ? value : value.slice(0, end);
interface RootSessionAttribution {
detectedMember: string;
description: string;
firstTimestamp: string | null;
}
type LogCandidate =
| {
kind: 'subagent';
filePath: string;
sessionId: string;
fileName: string;
}
| {
kind: 'member_session';
filePath: string;
sessionId: string;
fileName: string;
};
export class TeamMemberLogsFinder {
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<
string,
{
@ -104,7 +117,10 @@ export class TeamMemberLogsFinder {
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
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(
@ -134,8 +150,8 @@ export class TeamMemberLogsFinder {
}
// ── Collect and parallel-scan subagent files ──
const candidates = await this.collectSubagentCandidates(projectDir, sessionIds);
const settled: (MemberSubagentLogSummary | null)[] = new Array(candidates.length).fill(null);
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null);
let nextIdx = 0;
const scanWorker = async (): Promise<void> => {
@ -152,17 +168,27 @@ export class TeamMemberLogsFinder {
continue;
}
}
const summary = await this.parseSubagentSummary(
c.filePath,
projectId,
c.sessionId,
c.fileName,
memberName,
knownMembers
);
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 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);
}
const discovery = await this.discoverProjectSessions(teamName);
const discovery = await this.discoverProjectSessions(teamName, options);
if (!discovery) {
return null;
}
@ -258,7 +284,7 @@ export class TeamMemberLogsFinder {
const tLead = performance.now();
// ── 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 ──
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)))
continue;
mentionHits++;
const attribution = await this.attributeSubagent(c.filePath, knownMembers);
if (!attribution) continue;
const summary = await this.parseSubagentSummary(
c.filePath,
projectId,
c.sessionId,
c.fileName,
attribution.detectedMember,
knownMembers,
attribution
);
const summary =
c.kind === 'subagent'
? await (async (): Promise<MemberLogSummary | null> => {
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<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;
} 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 =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
: 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}`
: `lead:${log.sessionId}`;
: log.kind === 'member_session'
? `member:${log.sessionId}`
: `lead:${log.sessionId}`;
if (!seen.has(key)) {
seen.add(key);
results.push(log);
@ -455,16 +506,28 @@ export class TeamMemberLogsFinder {
const sinceMs = this.deriveSinceMs(options);
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 leadMemberName =
config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead';
const pushRef = (filePath: string, memberName: string, sortTime = 0): void => {
const key = `${memberName.toLowerCase()}:${filePath}`;
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({ filePath, memberName, sortTime });
refs.push({ kind, filePath, memberName, sessionId, sortTime });
};
if (config.leadSessionId) {
@ -473,7 +536,13 @@ export class TeamMemberLogsFinder {
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));
pushRef(
leadJsonl,
leadMemberName,
await this.getSortTime(leadJsonl, firstTimestamp),
'lead_session',
config.leadSessionId
);
}
} catch {
// file missing or unreadable
@ -482,7 +551,7 @@ export class TeamMemberLogsFinder {
const tLead = performance.now();
// ── 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 ──
let nextIdx = 0;
@ -496,15 +565,20 @@ export class TeamMemberLogsFinder {
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
continue;
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;
pushRef(
c.filePath,
attribution.detectedMember,
await this.getSortTime(c.filePath, attribution.firstTimestamp)
await this.getSortTime(c.filePath, attribution.firstTimestamp),
c.kind,
c.sessionId
);
} 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(
log.filePath,
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 leadRefs: (typeof refs)[0][] = [];
const memberSessionRefsByKey = new Map<string, (typeof refs)[0]>();
for (const ref of refs) {
if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) {
if (ref.kind === 'lead_session') {
leadRefs.push(ref);
continue;
}
const parts = ref.filePath.split(path.sep);
const subagentsIdx = parts.lastIndexOf('subagents');
const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : '';
const key = `${sessionId}:${ref.memberName.toLowerCase()}`;
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, ...refsByKey.values());
refs.push(...leadRefs, ...memberSessionRefsByKey.values(), ...refsByKey.values());
}
const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime);
@ -650,43 +734,32 @@ export class TeamMemberLogsFinder {
}
}
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let files: string[];
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
for (const candidate of candidates) {
let mtimeMs = 0;
try {
files = await fs.readdir(subagentsDir);
mtimeMs = (await fs.stat(candidate.filePath)).mtimeMs;
} catch {
continue;
}
for (const file of files) {
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
if (file.startsWith('agent-acompact')) continue;
const filePath = path.join(subagentsDir, file);
// Quick attribution check — only Phase 1 (no full-file streaming)
let mtimeMs = 0;
try {
mtimeMs = (await fs.stat(filePath)).mtimeMs;
} catch {
continue;
}
const attribution = await this.getCachedSubagentAttribution(
filePath,
knownMembers,
mtimeMs
);
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
paths.push(filePath);
}
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 listAttributedSubagentFiles(
async listAttributedMemberFiles(
teamName: string
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> {
const discovery = await this.discoverProjectSessions(teamName);
@ -697,13 +770,7 @@ export class TeamMemberLogsFinder {
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
// Live teammate tool tracking should follow the current team run, not historical
// 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 candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
const results: {
memberName: string;
sessionId: string;
@ -714,12 +781,27 @@ export class TeamMemberLogsFinder {
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 = await this.getCachedSubagentAttribution(
candidate.filePath,
knownMembers,
stat.mtimeMs
);
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,
@ -737,7 +819,34 @@ export class TeamMemberLogsFinder {
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;
}
private async discoverProjectSessions(teamName: string): Promise<{
private async discoverProjectSessions(
teamName: string,
options?: { forceRefresh?: boolean }
): Promise<{
projectDir: string;
projectId: string;
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
sessionIds: string[];
knownMembers: Set<string>;
} | null> {
// 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;
if (options?.forceRefresh) {
this.discoveryCache.delete(teamName);
} else {
// 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);
if (!config?.projectPath) {
logger.debug(`No projectPath for team "${teamName}"`);
const context = await this.projectResolver.getContext(teamName, options);
if (!context) {
logger.debug(`No transcript context for team "${teamName}"`);
return null;
}
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 { config, projectDir, projectId, sessionIds } = context;
const knownMembers = new Set<string>(
(config.members ?? [])
@ -927,16 +971,42 @@ export class TeamMemberLogsFinder {
return { ...discovery, isLeadMember };
}
/**
* Collect all subagent JSONL file candidates across session directories.
* Filters out non-agent files and compact files (agent-acompact*).
*/
private async collectSubagentCandidates(
private async collectLogCandidates(
projectDir: string,
sessionIds: string[]
): Promise<{ filePath: string; sessionId: string; fileName: string }[]> {
const candidates: { filePath: string; sessionId: string; fileName: string }[] = [];
sessionIds: string[],
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>
): 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) {
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 {
@ -947,7 +1017,12 @@ export class TeamMemberLogsFinder {
for (const f of dirFiles) {
if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact'))
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;
@ -1201,16 +1276,6 @@ export class TeamMemberLogsFinder {
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(
filePath: string,
knownMembers: Set<string>,
@ -1229,6 +1294,25 @@ export class TeamMemberLogsFinder {
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(
filePath: 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
* and extract a human-readable description from the first user message.
@ -1421,6 +1559,122 @@ export class TeamMemberLogsFinder {
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.
* 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);
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);
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 path from 'path';
import { TeamConfigReader } from '../../TeamConfigReader';
import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver';
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 {
projectDir: string;
projectId: string;
@ -31,50 +14,17 @@ export interface TeamTranscriptSourceContext {
}
export class TeamTranscriptSourceLocator {
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
constructor(
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver()
) {}
async getContext(teamName: string): Promise<TeamTranscriptSourceContext | null> {
const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) {
const context = await this.projectResolver.getContext(teamName);
if (!context) {
return null;
}
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');
}
} 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 { projectDir, projectId, config, sessionIds } = context;
const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds);
return { projectDir, projectId, config, sessionIds, transcriptFiles };
}
@ -83,51 +33,6 @@ export class TeamTranscriptSourceLocator {
const context = await this.getContext(teamName);
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(
projectDir: 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 => {
return log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
if (log.kind === 'subagent') {
return `subagent:${log.sessionId}:${log.subagentId}`;
}
if (log.kind === 'member_session') {
return `member:${log.sessionId}`;
}
return `lead:${log.sessionId}`;
}, []);
const sortedLogs = useMemo(() => {
@ -250,7 +254,9 @@ export const MemberLogsTab = ({
if (!shouldShowPreview) return null;
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 (taskOwner) {
@ -515,6 +521,10 @@ export const MemberLogsTab = ({
);
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);
return d ? asEnhancedChunkArray(d.chunks) : null;
},
@ -766,7 +776,7 @@ const LogCard = ({
<span className="truncate text-[var(--color-text)]" title={log.description}>
{log.description}
</span>
{log.kind === 'lead_session' && (
{(log.kind === 'lead_session' || log.kind === 'member_session') && (
<Tooltip>
<TooltipTrigger asChild>
<span
@ -777,8 +787,9 @@ const LogCard = ({
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-center">
Full team lead session logs useful for global orchestration context, not
specific to this agent
{log.kind === 'lead_session'
? '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>
</Tooltip>
)}
@ -838,7 +849,11 @@ const LogCard = ({
<div className="w-full min-w-0">
<MemberExecutionLog
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>
)}

View file

@ -1040,7 +1040,7 @@ export interface MemberSubagentSummary {
isOngoing: boolean;
}
export type MemberLogKind = 'subagent' | 'lead_session';
export type MemberLogKind = 'subagent' | 'lead_session' | 'member_session';
export interface MemberLogSummaryBase {
kind: MemberLogKind;
@ -1071,7 +1071,14 @@ export interface MemberLeadSessionLogSummary extends MemberLogSummaryBase {
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 {
added: number;

View file

@ -97,6 +97,118 @@ describe('TeamMemberLogsFinder', () => {
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 () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
@ -841,7 +953,7 @@ describe('TeamMemberLogsFinder', () => {
{
type: 'tool_use',
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'));
});
});