diff --git a/README.md b/README.md
index f90d0989..94ab9335 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
## Installation
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
+If you want the freshest version, clone the repo and run it from the `dev` branch.
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index 5e02090e..f05a0e5a 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -99,18 +99,19 @@ import * as path from 'path';
import { ConfigManager } from '../services/infrastructure/ConfigManager';
import { NotificationManager } from '../services/infrastructure/NotificationManager';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
-import {
- getAutoResumeService,
- initializeAutoResumeService,
-} from '../services/team/AutoResumeService';
import {
buildActionModeAgentBlock,
isAgentActionMode,
} from '../services/team/actionModeInstructions';
+import {
+ getAutoResumeService,
+ initializeAutoResumeService,
+} from '../services/team/AutoResumeService';
import {
buildReplaceMembersDiff,
buildReplaceMembersSummaryMessage,
} from '../services/team/memberUpdateNotifications';
+import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
@@ -156,11 +157,9 @@ import type {
IpcResult,
KanbanColumnId,
LeadActivitySnapshot,
- LeadContextUsage,
LeadContextUsageSnapshot,
MemberFullStats,
MemberLogSummary,
- MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
MessagesPage,
SendMessageRequest,
@@ -823,82 +822,23 @@ async function handleGetData(
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive } };
}
-
- const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
- const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
- !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
- const getLeadThoughtFingerprint = (msg: {
- from: string;
- text: string;
- leadSessionId?: string;
- }): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
-
- // Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
- // repeated thought in a new session does not get collapsed into an old session's history.
- const existingTextFingerprints = new Set();
- for (const msg of data.messages) {
- if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
- if (!isLeadThoughtLike(msg)) continue;
- existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
+ let merged = mergeLiveLeadProcessMessages(data.messages, live);
+ if (data.messages.length >= 50) {
+ try {
+ const newestPage = await teamDataService.getMessagesPage(tn, {
+ limit: 50,
+ liveMessages: live,
+ });
+ merged = newestPage.messages;
+ } catch (error) {
+ logger.warn(
+ `[teams:getData] failed to rebuild newest merged messages for ${tn}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
}
- const keyFor = (m: {
- messageId?: string;
- timestamp: string;
- from: string;
- text: string;
- }): string => {
- if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
- return m.messageId;
- }
- return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
- };
-
- // Text-based fingerprints for live lead thoughts to catch duplicates with different
- // messageIds inside the same session (e.g. lead-turn-* re-emits).
- const leadProcessTextFingerprints = new Set();
-
- // Content-based dedup for SendMessage captures: Claude Code CLI and our
- // persistInboxMessage both write to inboxes/{member}.json, producing two entries
- // with identical content but different messageIds. Track content fingerprints
- // (from+to+text) with timestamps to collapse them within a 5-second window.
- const contentSeen = new Map(); // fingerprint → timestamp ms
-
- const merged: typeof data.messages = [];
- const seen = new Set();
- for (const msg of [...data.messages, ...live]) {
- if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
- const fp = getLeadThoughtFingerprint(msg);
- // Skip if the same thought already exists in persisted history for the same session.
- if (existingTextFingerprints.has(fp)) {
- continue;
- }
- // Dedup live lead_process thoughts with the same text in the same session.
- if (leadProcessTextFingerprints.has(fp)) {
- continue;
- }
- leadProcessTextFingerprints.add(fp);
- }
-
- // Content dedup for directed messages (SendMessage captures):
- // same from+to+text within 5 seconds = duplicate from CLI + our persist.
- if (typeof msg.to === 'string' && msg.to.trim().length > 0) {
- const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`;
- const msgMs = Date.parse(msg.timestamp);
- const existingMs = contentSeen.get(contentFp);
- if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) {
- continue; // duplicate within 5s window — skip
- }
- contentSeen.set(contentFp, msgMs);
- }
-
- const key = keyFor(msg);
- if (seen.has(key)) continue;
- seen.add(key);
- merged.push(msg);
- }
- merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
-
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
checkApiErrorMessages(merged, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive, messages: merged } };
@@ -1789,7 +1729,10 @@ async function handleGetMessagesPage(
return wrapTeamHandler('getMessagesPage', async () => {
const service = getTeamDataService();
- return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
+ const liveMessages = beforeTimestamp
+ ? undefined
+ : getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!);
+ return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit, liveMessages });
});
}
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 307cfa67..f1d8a119 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -1,12 +1,5 @@
import { yieldToEventLoop } from '@main/utils/asyncYield';
-import {
- encodePath,
- extractBaseDir,
- getClaudeBasePath,
- getProjectsBasePath,
- getTasksBasePath,
- getTeamsBasePath,
-} from '@main/utils/pathDecoder';
+import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill';
import {
AGENT_BLOCK_CLOSE,
@@ -16,7 +9,7 @@ import {
} from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
-import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
+import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
@@ -39,6 +32,10 @@ import {
} from './cache/LeadSessionParseCache';
import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
+import {
+ getLiveLeadProcessMessageKey,
+ mergeLiveLeadProcessMessages,
+} from './mergeLiveLeadProcessMessages';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
@@ -52,6 +49,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
+import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
@@ -182,7 +180,10 @@ export class TeamDataService {
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
- private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache()
+ private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
+ private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
+ configReader
+ )
) {}
private getController(teamName: string): AgentTeamsController {
@@ -769,7 +770,7 @@ export class TeamDataService {
label: 'leadTexts',
createFallback: () => [],
warningText: 'Lead session texts failed to load',
- load: () => this.extractLeadSessionTexts(config),
+ load: () => this.extractLeadSessionTexts(teamName, config),
})
);
@@ -1113,7 +1114,7 @@ export class TeamDataService {
*/
async getMessagesPage(
teamName: string,
- options: { beforeTimestamp?: string; limit: number }
+ options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] }
): Promise {
const config = await this.configReader.getConfig(teamName);
if (!config) {
@@ -1125,7 +1126,7 @@ export class TeamDataService {
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
- this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
+ this.extractLeadSessionTexts(teamName, config).catch(() => [] as InboxMessage[]),
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
]);
@@ -1190,6 +1191,11 @@ export class TeamDataService {
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
});
+ const newestDurableMessages = messages;
+ const durableMessageIndexByKey = new Map(
+ newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
+ );
+
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
// to handle multiple messages sharing the same timestamp.
if (options.beforeTimestamp) {
@@ -1212,7 +1218,54 @@ export class TeamDataService {
const nextCursor =
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
- return { messages: page, nextCursor, hasMore };
+ if (options.beforeTimestamp || !options.liveMessages?.length) {
+ return { messages: page, nextCursor, hasMore };
+ }
+
+ // Merge live lead thoughts against the full durable newest-page history so we do not
+ // re-introduce persisted thoughts that have simply paged off the first durable page.
+ const displayMessages = mergeLiveLeadProcessMessages(
+ newestDurableMessages,
+ options.liveMessages
+ ).slice(0, options.limit);
+
+ if (displayMessages.length === 0) {
+ return { messages: displayMessages, nextCursor: null, hasMore: false };
+ }
+
+ let lastDurableDisplayed: InboxMessage | null = null;
+ for (let index = displayMessages.length - 1; index >= 0; index -= 1) {
+ const candidate = displayMessages[index];
+ if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) {
+ lastDurableDisplayed = candidate;
+ break;
+ }
+ }
+
+ if (!lastDurableDisplayed) {
+ const boundary = displayMessages[displayMessages.length - 1];
+ return {
+ messages: displayMessages,
+ nextCursor:
+ newestDurableMessages.length > 0
+ ? `${boundary.timestamp}|${boundary.messageId ?? ''}`
+ : null,
+ hasMore: newestDurableMessages.length > 0,
+ };
+ }
+
+ const durableIndex =
+ durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ??
+ Number.POSITIVE_INFINITY;
+ const durableHasMore = durableIndex < newestDurableMessages.length - 1;
+
+ return {
+ messages: displayMessages,
+ nextCursor: durableHasMore
+ ? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
+ : null,
+ hasMore: durableHasMore,
+ };
}
/**
@@ -2614,37 +2667,20 @@ export class TeamDataService {
}
}
- private getLeadProjectDirCandidates(projectPath: string): string[] {
- const projectId = encodePath(projectPath);
- const baseDir = extractBaseDir(projectId);
- const candidateDirs = [
- path.join(getProjectsBasePath(), baseDir),
- // Claude Code encodes underscores as hyphens in project directory names;
- // our encodePath only handles slashes. Try the underscore-to-hyphen variant.
- ...(baseDir.includes('_')
- ? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))]
- : []),
- ];
-
- return [...new Set(candidateDirs)];
- }
-
- private async getLeadSessionJsonlPaths(projectPath: string): Promise