agent-ecosystem/src/main/services/team/TeamTranscriptProjectResolver.ts

1041 lines
31 KiB
TypeScript

import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { extractCwd } from '@main/utils/jsonl';
import {
encodePath,
extractBaseDir,
getProjectsBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { isLeadMember } from '@shared/utils/leadDetection';
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;
const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000;
type ProjectEvidenceSource =
| 'projectPath'
| 'projectPathHistory'
| 'leadCwd'
| 'memberCwd'
| 'projectsScan';
interface ProjectPathCandidate {
projectPath: string;
source: Exclude<ProjectEvidenceSource, 'projectsScan'>;
}
interface ProjectDirCandidate {
projectPath: string;
projectDir: string;
projectId: string;
source: ProjectEvidenceSource;
}
interface SessionProjectMatch extends ProjectDirCandidate {
matchedSessionId: string;
}
interface TeamTranscriptProjectConfigReader {
getConfig(teamName: string): Promise<TeamConfig | null>;
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
}
interface TeamTranscriptProjectContextOptions {
forceRefresh?: boolean;
includeTeamSubagentSessionDiscovery?: boolean;
}
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
projectPath?: string;
};
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 buildContextCacheKey(
teamName: string,
options?: TeamTranscriptProjectContextOptions
): string {
const subagentMode = options?.includeTeamSubagentSessionDiscovery === false ? 'known' : 'full';
return `${teamName}\0${subagentMode}`;
}
function parseTimestampMs(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function teamLifecycleMtimeCutoffMs(config: TeamConfig): number | null {
const timestamps: number[] = [];
const createdAt = parseTimestampMs((config as { createdAt?: unknown }).createdAt);
if (createdAt !== null) {
timestamps.push(createdAt);
}
for (const member of config.members ?? []) {
const joinedAt = parseTimestampMs((member as { joinedAt?: unknown }).joinedAt);
if (joinedAt !== null) {
timestamps.push(joinedAt);
}
}
if (timestamps.length === 0) {
return null;
}
return Math.max(0, Math.min(...timestamps) - FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS);
}
function normalizeProjectPathCandidate(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return trimTrailingSlashes(trimmed);
}
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(`team name: ${normalizedTeam}`) ||
normalizedText.includes(`team name "${normalizedTeam}"`) ||
normalizedText.includes(`team name '${normalizedTeam}'`) ||
normalizedText.includes(`on team "${normalizedTeam}"`) ||
normalizedText.includes(`on team '${normalizedTeam}'`) ||
normalizedText.includes(`team "${normalizedTeam}"`) ||
normalizedText.includes(`team '${normalizedTeam}'`) ||
normalizedText.includes(`(${normalizedTeam})`)
);
}
function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean {
if (!value || depth > 8 || typeof value !== 'object') {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1));
}
const entry = value as Record<string, unknown>;
if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) {
return true;
}
return Object.entries(entry).some(([key, nested]) => {
if (key === 'teamName') {
return false;
}
return entryContainsNestedTeamName(nested, teamName, depth + 1);
});
}
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 (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) {
const sessionId = config.sessionHistory[index];
push(sessionId);
}
}
return [...knownSessionIds];
}
export interface TeamTranscriptProjectContext {
projectDir: string;
projectId: string;
config: TeamConfig;
sessionIds: string[];
}
export interface TeamTranscriptProjectLiveBaseContext {
projectDir: string;
projectId: string;
config: TeamConfig;
}
export class TeamTranscriptProjectResolver {
private readonly contextCache = new Map<
string,
{ value: TeamTranscriptProjectContext; expiresAt: number }
>();
constructor(
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
) {}
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
return typeof this.configReader.getConfigSnapshot === 'function'
? this.configReader.getConfigSnapshot(teamName)
: this.configReader.getConfig(teamName);
}
private deleteContextCacheForTeam(teamName: string): void {
this.contextCache.delete(teamName);
for (const key of this.contextCache.keys()) {
if (key === teamName || key.startsWith(`${teamName}\0`)) {
this.contextCache.delete(key);
}
}
}
async getLiveBaseContext(
teamName: string,
options?: { forceRefresh?: boolean; extraProjectPathCandidates?: readonly unknown[] }
): Promise<TeamTranscriptProjectLiveBaseContext | null> {
if (options?.forceRefresh) {
this.deleteContextCacheForTeam(teamName);
}
const config = await this.readConfigForObservation(teamName);
if (!config) {
return null;
}
const projectPathCandidates = this.collectLiveProjectPathCandidates(
config,
options?.extraProjectPathCandidates ?? []
);
const resolution = await this.resolveLiveProjectDirectoryFromCandidates(projectPathCandidates);
if (!resolution) {
return null;
}
const resolvedConfig =
trimTrailingSlashes(config.projectPath ?? '') !==
trimTrailingSlashes(resolution.effectiveProjectPath)
? { ...config, projectPath: resolution.effectiveProjectPath }
: config;
return {
projectDir: resolution.projectDir,
projectId: resolution.projectId,
config: resolvedConfig,
};
}
async getContext(
teamName: string,
options?: TeamTranscriptProjectContextOptions
): Promise<TeamTranscriptProjectContext | null> {
const cacheKey = buildContextCacheKey(teamName, options);
if (options?.forceRefresh) {
this.deleteContextCacheForTeam(teamName);
}
const cached = this.contextCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const config = await this.readConfigForObservation(teamName);
if (!config) {
return null;
}
const resolution = await this.resolveProjectDirectory(teamName, config);
if (!resolution) {
return null;
}
const resolvedConfig =
resolution.effectiveProjectPath &&
trimTrailingSlashes(resolution.effectiveProjectPath) !==
trimTrailingSlashes(config.projectPath ?? '')
? {
...config,
projectPath: resolution.effectiveProjectPath,
projectPathHistory: this.buildRepairedProjectPathHistory(
config.projectPath,
config.projectPathHistory,
resolution.effectiveProjectPath
),
}
: config;
const sessionIds = await this.discoverSessionIds(
teamName,
resolution.projectDir,
resolvedConfig,
options
);
const value = {
projectDir: resolution.projectDir,
projectId: resolution.projectId,
config: resolvedConfig,
sessionIds,
};
this.contextCache.set(cacheKey, {
value,
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
});
return value;
}
private async resolveProjectDirectory(
teamName: string,
config: TeamConfig
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
const sessionIds = collectKnownSessionIds(config);
const pathCandidates = this.collectProjectPathCandidates(config);
const currentCandidate = pathCandidates[0] ?? null;
if (sessionIds.length === 0) {
return this.buildFallbackResolution(teamName, pathCandidates);
}
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
const getMatchRank = (match: { matchedSessionId: string } | null): number =>
match
? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY)
: Number.POSITIVE_INFINITY;
const toResolution = (
match: Pick<ProjectDirCandidate, 'projectDir' | 'projectId'> & { projectPath?: string }
): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({
projectDir: match.projectDir,
projectId: match.projectId,
...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}),
});
let currentMatch: SessionProjectMatch | null = null;
if (currentCandidate) {
const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate(
currentCandidate,
sessionIds
);
if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) {
return toResolution(resolvedCurrentMatch);
}
if (resolvedCurrentMatch) {
currentMatch = resolvedCurrentMatch;
}
}
const configuredMatches =
pathCandidates.length > 1
? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds)
: [];
const scannedMatches = await this.findMatchesByScanningProjects(sessionIds);
const candidateMatchesByProjectDir = new Map<
string,
SessionProjectMatch | ScannedSessionProjectMatch
>();
for (const match of configuredMatches) {
if (match.projectDir === currentMatch?.projectDir) {
continue;
}
candidateMatchesByProjectDir.set(match.projectDir, match);
}
for (const match of scannedMatches) {
if (match.projectDir === currentMatch?.projectDir) {
continue;
}
if (!candidateMatchesByProjectDir.has(match.projectDir)) {
candidateMatchesByProjectDir.set(match.projectDir, match);
}
}
const alternateMatches = [...candidateMatchesByProjectDir.values()];
const bestAlternateRank = alternateMatches.reduce(
(best, match) => Math.min(best, getMatchRank(match)),
Number.POSITIVE_INFINITY
);
const currentRank = getMatchRank(currentMatch);
if (currentMatch && currentRank <= bestAlternateRank) {
return toResolution(currentMatch);
}
if (bestAlternateRank !== Number.POSITIVE_INFINITY) {
const bestAlternates = alternateMatches.filter(
(match) => getMatchRank(match) === bestAlternateRank
);
if (bestAlternates.length === 1) {
const winner = bestAlternates[0];
if (winner.projectPath) {
await this.persistResolvedProjectPath(teamName, config, winner.projectPath);
}
return toResolution(winner);
}
logger.warn(
`[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path`
);
return currentMatch
? toResolution(currentMatch)
: this.buildFallbackResolution(teamName, pathCandidates);
}
if (currentMatch) {
return toResolution(currentMatch);
}
return this.buildFallbackResolution(teamName, pathCandidates);
}
private async buildFallbackResolution(
teamName: string,
candidates: readonly ProjectPathCandidate[]
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
let firstResolution: {
projectDir: string;
projectId: string;
effectiveProjectPath?: string;
} | null = null;
let firstExistingResolution: {
projectDir: string;
projectId: string;
effectiveProjectPath?: string;
} | null = null;
for (const candidate of candidates) {
for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
const resolution = {
projectDir: dirCandidate.projectDir,
projectId: dirCandidate.projectId,
effectiveProjectPath: candidate.projectPath,
};
if (!firstResolution) {
firstResolution = resolution;
}
if (!(await this.projectDirExists(dirCandidate.projectDir))) {
continue;
}
if (!firstExistingResolution) {
firstExistingResolution = resolution;
}
const teamRootSessionIds = await this.listTeamRootSessionIds(
dirCandidate.projectDir,
teamName
);
if (teamRootSessionIds.length > 0) {
return resolution;
}
}
}
return firstExistingResolution ?? firstResolution;
}
private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] {
const candidates: ProjectPathCandidate[] = [];
const seen = new Set<string>();
const push = (value: unknown, source: Exclude<ProjectEvidenceSource, 'projectsScan'>): void => {
const normalized = normalizeProjectPathCandidate(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push({ projectPath: normalized, source });
};
push(config.projectPath, 'projectPath');
if (Array.isArray(config.projectPathHistory)) {
for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) {
push(config.projectPathHistory[index], 'projectPathHistory');
}
}
const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd;
push(leadCwd, 'leadCwd');
const distinctMemberCwds = Array.from(
new Set(
(config.members ?? [])
.map((member) => normalizeProjectPathCandidate(member.cwd))
.filter((cwd): cwd is string => Boolean(cwd))
)
);
if (distinctMemberCwds.length === 1) {
push(distinctMemberCwds[0], 'memberCwd');
}
return candidates;
}
private collectLiveProjectPathCandidates(
config: TeamConfig,
extraCandidates: readonly unknown[]
): string[] {
const candidates: string[] = [];
const seen = new Set<string>();
const push = (value: unknown): void => {
const normalized = normalizeProjectPathCandidate(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push(normalized);
};
push(config.projectPath);
const history = Array.isArray(config.projectPathHistory) ? config.projectPathHistory : [];
for (let index = history.length - 1; index >= Math.max(0, history.length - 5); index -= 1) {
push(history[index]);
}
push((config.members ?? []).find((member) => isLeadMember(member))?.cwd);
const distinctMemberCwds = new Set(
(config.members ?? [])
.map((member) => normalizeProjectPathCandidate(member.cwd))
.filter((cwd): cwd is string => Boolean(cwd))
);
if (distinctMemberCwds.size === 1) {
push([...distinctMemberCwds][0]);
}
for (const candidate of extraCandidates.slice(0, 64)) {
push(candidate);
}
return candidates;
}
private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] {
const normalizedProjectPath = trimTrailingSlashes(projectPath);
const projectId = extractBaseDir(encodePath(normalizedProjectPath));
const baseCandidates = [
{ projectDir: path.join(getProjectsBasePath(), projectId), projectId },
...(projectId.includes('_')
? [
{
projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')),
projectId: projectId.replace(/_/g, '-'),
},
]
: []),
];
const seen = new Set<string>();
return baseCandidates
.filter((candidate) => {
if (seen.has(candidate.projectDir)) {
return false;
}
seen.add(candidate.projectDir);
return true;
})
.map((candidate) => ({
projectPath: normalizedProjectPath,
projectDir: candidate.projectDir,
projectId: candidate.projectId,
source: 'projectPath' as const,
}));
}
private async resolveLiveProjectDirectoryFromCandidates(
candidates: readonly string[]
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath: string } | null> {
let firstResolution: {
projectDir: string;
projectId: string;
effectiveProjectPath: string;
} | null = null;
for (const projectPath of candidates) {
for (const dirCandidate of this.buildProjectDirCandidates(projectPath)) {
const resolution = {
projectDir: dirCandidate.projectDir,
projectId: dirCandidate.projectId,
effectiveProjectPath: projectPath,
};
firstResolution ??= resolution;
if (await this.projectDirExists(dirCandidate.projectDir)) {
return resolution;
}
}
}
return null;
}
private async findMatchInProjectPathCandidate(
candidate: ProjectPathCandidate,
sessionIds: string[]
): Promise<SessionProjectMatch | null> {
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
let bestMatch: SessionProjectMatch | null = null;
for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
const matchedSessionId = await this.findMatchingSessionId(
projectCandidate.projectDir,
sessionIds
);
if (!matchedSessionId) {
continue;
}
const match = {
...projectCandidate,
source: candidate.source,
matchedSessionId,
};
const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY;
const bestRank = bestMatch
? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY)
: Number.POSITIVE_INFINITY;
if (!bestMatch || matchRank < bestRank) {
bestMatch = match;
}
if (matchRank === 0) {
break;
}
}
return bestMatch;
}
private async findMatchesInProjectPathCandidates(
candidates: ProjectPathCandidate[],
sessionIds: string[]
): Promise<SessionProjectMatch[]> {
const matches: SessionProjectMatch[] = [];
const seenProjectDirs = new Set<string>();
for (const candidate of candidates) {
const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds);
if (!match || seenProjectDirs.has(match.projectDir)) {
continue;
}
seenProjectDirs.add(match.projectDir);
matches.push(match);
}
return matches;
}
private async findMatchingSessionId(
projectDir: string,
sessionIds: string[]
): Promise<string | null> {
for (const sessionId of sessionIds) {
try {
const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`));
if (stat.isFile()) {
return sessionId;
}
} catch {
// continue
}
}
return null;
}
private async findMatchesByScanningProjects(
sessionIds: string[]
): Promise<ScannedSessionProjectMatch[]> {
let projectEntries: Dirent[];
try {
projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
} catch {
return [];
}
const directories = projectEntries.filter((entry) => entry.isDirectory());
const matches: ScannedSessionProjectMatch[] = [];
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < directories.length) {
const index = nextIndex++;
const entry = directories[index];
const projectDir = path.join(getProjectsBasePath(), entry.name);
const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds);
if (!matchedSessionId) {
continue;
}
const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`);
const cwd = await extractCwd(jsonlPath);
matches.push({
projectPath: cwd ?? undefined,
projectDir,
projectId: entry.name,
source: 'projectsScan',
matchedSessionId,
});
}
};
await Promise.all(
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () =>
worker()
)
);
const deduped = new Map<string, ScannedSessionProjectMatch>();
for (const match of matches) {
if (!deduped.has(match.projectDir)) {
deduped.set(match.projectDir, match);
}
}
return [...deduped.values()];
}
private async persistResolvedProjectPath(
teamName: string,
config: TeamConfig,
nextProjectPath: string
): Promise<void> {
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
if (!normalizedNextPath) {
return;
}
const currentProjectPath = normalizeProjectPathCandidate(config.projectPath);
if (currentProjectPath === normalizedNextPath) {
return;
}
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.readFile(configPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const rawProjectPath =
normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null;
parsed.projectPath = normalizedNextPath;
parsed.projectPathHistory = this.buildRepairedProjectPathHistory(
rawProjectPath,
parsed.projectPathHistory,
normalizedNextPath
);
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
TeamConfigReader.invalidateTeam(teamName);
logger.info(
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
);
} catch (error) {
logger.warn(
`[${teamName}] Failed to persist repaired transcript projectPath: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
private async discoverSessionIds(
teamName: string,
projectDir: string,
config: TeamConfig,
options?: TeamTranscriptProjectContextOptions
): Promise<string[]> {
const knownSessionIds = collectKnownSessionIds(config);
const includeTeamSubagentSessionDiscovery =
options?.includeTeamSubagentSessionDiscovery !== false;
const rootMtimeSinceMs = includeTeamSubagentSessionDiscovery
? null
: teamLifecycleMtimeCutoffMs(config);
const [teamRootSessionIds, teamSubagentSessionIds] = await Promise.all([
this.listTeamRootSessionIds(projectDir, teamName, rootMtimeSinceMs),
includeTeamSubagentSessionDiscovery
? this.listTeamSubagentSessionIds(projectDir, teamName)
: Promise.resolve([]),
]);
const orderedSessionIds: string[] = [];
const seen = new Set<string>();
const push = (sessionId: string): void => {
if (seen.has(sessionId)) {
return;
}
seen.add(sessionId);
orderedSessionIds.push(sessionId);
};
for (const sessionId of knownSessionIds) {
push(sessionId);
}
for (const sessionId of [...teamRootSessionIds, ...teamSubagentSessionIds].sort((left, right) =>
left.localeCompare(right)
)) {
push(sessionId);
}
return orderedSessionIds;
}
private buildRepairedProjectPathHistory(
currentProjectPath: unknown,
rawProjectPathHistory: unknown,
nextProjectPath: string
): string[] {
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
const history: string[] = [];
const seen = new Set<string>();
const pushHistory = (value: unknown): void => {
const normalized = normalizeProjectPathCandidate(value);
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
return;
}
seen.add(normalized);
history.push(normalized);
};
if (Array.isArray(rawProjectPathHistory)) {
for (const value of rawProjectPathHistory) {
pushHistory(value);
}
}
pushHistory(currentProjectPath);
return history.slice(-500);
}
private async projectDirExists(projectDir: string): Promise<boolean> {
try {
const stat = await fs.stat(projectDir);
return stat.isDirectory();
} catch {
return false;
}
}
private async readProjectDirEntries(projectDir: string): Promise<Dirent[] | null> {
try {
return await fs.readdir(projectDir, { withFileTypes: true });
} catch {
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
return null;
}
}
private async listTeamSubagentSessionIds(
projectDir: string,
teamName: string
): Promise<string[]> {
const dirEntries = await this.readProjectDirEntries(projectDir);
if (!dirEntries) {
return [];
}
const sessionDirEntries = dirEntries.filter(
(entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)
);
const discovered = new Set<string>();
let nextIndex = 0;
const scanNextSessionDir = async (): Promise<void> => {
while (nextIndex < sessionDirEntries.length) {
const entry = sessionDirEntries[nextIndex++];
const subagentsDir = path.join(projectDir, entry.name, 'subagents');
let subagentEntries: Dirent[];
try {
subagentEntries = await fs.readdir(subagentsDir, { withFileTypes: true });
} catch {
continue;
}
for (const subagentEntry of subagentEntries) {
if (!subagentEntry.isFile()) {
continue;
}
if (!subagentEntry.name.endsWith('.jsonl')) {
continue;
}
if (!subagentEntry.name.startsWith('agent-')) {
continue;
}
if (subagentEntry.name.startsWith('agent-acompact')) {
continue;
}
const filePath = path.join(subagentsDir, subagentEntry.name);
if (await this.fileBelongsToTeam(filePath, teamName)) {
discovered.add(entry.name);
break;
}
}
}
};
await Promise.all(
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, sessionDirEntries.length) }, () =>
scanNextSessionDir()
)
);
return [...discovered];
}
private async collectRootJsonlSessionIds(
rootJsonlEntries: Dirent[],
projectDir: string,
teamName: string,
mtimeSinceMs?: number | null
): Promise<string[]> {
const discovered = new Set<string>();
let nextIndex = 0;
const scanNextRootEntry = async (): Promise<void> => {
while (nextIndex < rootJsonlEntries.length) {
const entry = rootJsonlEntries[nextIndex++];
const filePath = path.join(projectDir, entry.name);
if (mtimeSinceMs != null) {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile() || stat.mtimeMs < mtimeSinceMs) {
continue;
}
} catch {
continue;
}
}
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) }, () =>
scanNextRootEntry()
)
);
return [...discovered];
}
private async listTeamRootSessionIds(
projectDir: string,
teamName: string,
mtimeSinceMs?: number | null
): Promise<string[]> {
const dirEntries = await this.readProjectDirEntries(projectDir);
if (!dirEntries) {
return [];
}
const rootJsonlEntries = dirEntries.filter(
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
);
return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName, mtimeSinceMs);
}
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;
}
if (entryContainsNestedTeamName(entry, 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;
}
}