feat: enhance task comment notifications and owner wake-up logic

- Introduced a new function to notify task owners on comments from other members, improving communication and responsiveness.
- Updated existing comment handling functions to include logic for notifying owners based on comment type and author.
- Added tests to validate the new notification behavior, ensuring that owners are correctly alerted for relevant comments while avoiding unnecessary notifications for self-comments.
- Refactored task management logic to streamline comment processing and notification handling.
This commit is contained in:
iliya 2026-03-09 15:25:22 +02:00
parent 9678d790cd
commit 9adffb2295
22 changed files with 449 additions and 119 deletions

View file

@ -131,6 +131,7 @@ function approveReview(context, taskId, flags = {}) {
text: note,
from,
type: 'review_approved',
notifyOwner: false,
});
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
@ -184,6 +185,7 @@ function requestChanges(context, taskId, flags = {}) {
text: comment,
from,
type: 'review_request',
notifyOwner: false,
});
messages.sendMessage(context, {
to: task.owner,

View file

@ -413,6 +413,10 @@ function updateTaskFields(paths, taskRef, fields) {
});
}
function normalizeMemberName(value) {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
function addTaskComment(paths, taskRef, text, options = {}) {
if (typeof text !== 'string' || !text.trim()) {
throw new Error('Missing comment text');
@ -435,21 +439,31 @@ function addTaskComment(paths, taskRef, text, options = {}) {
: {}),
};
let inserted = false;
let clarificationCleared = false;
const task = updateTask(paths, taskRef, (currentTask) => {
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
if (comments.some((entry) => entry.id === comment.id)) {
return currentTask;
}
if (currentTask.needsClarification === 'lead' && comment.author !== currentTask.owner) {
const authorName = normalizeMemberName(comment.author);
const ownerName = normalizeMemberName(currentTask.owner);
if (currentTask.needsClarification === 'lead' && authorName && authorName !== ownerName) {
delete currentTask.needsClarification;
clarificationCleared = true;
}
if (currentTask.needsClarification === 'user' && authorName === 'user') {
delete currentTask.needsClarification;
clarificationCleared = true;
}
currentTask.comments = comments.concat([comment]);
inserted = true;
return currentTask;
});
return { comment, task };
return { comment, task, inserted, clarificationCleared };
}
function setNeedsClarification(paths, taskRef, value) {

View file

@ -11,6 +11,22 @@ function isSameMember(left, right) {
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
}
function isSameTaskMember(left, right, leadName) {
const normalizedLeft = normalizeActorName(left).toLowerCase();
const normalizedRight = normalizeActorName(right).toLowerCase();
const normalizedLead = normalizeActorName(leadName).toLowerCase();
if (!normalizedLeft || !normalizedRight) {
return false;
}
if (normalizedLeft === normalizedRight) {
return true;
}
return (
(normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) ||
(normalizedRight === 'team-lead' && normalizedLeft === normalizedLead)
);
}
function buildAssignmentMessage(context, task, options = {}) {
const description =
typeof options.description === 'string' && options.description.trim()
@ -45,6 +61,18 @@ function buildAssignmentMessage(context, task, options = {}) {
return lines.join('\n');
}
function buildCommentNotificationMessage(context, task, comment) {
const taskLabel = `#${task.displayId || task.id}`;
return [
`Comment on task ${taskLabel} "${task.subject}":`,
``,
comment.text,
``,
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
].join('\n');
}
function maybeNotifyAssignedOwner(context, task, options = {}) {
const owner = normalizeActorName(task.owner);
if (!owner || task.status === 'deleted') {
@ -69,6 +97,38 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
});
}
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
if (!options.inserted || options.notifyOwner === false) {
return;
}
if (!task || task.status === 'deleted') {
return;
}
if (comment.type && comment.type !== 'regular') {
return;
}
const owner = normalizeActorName(task.owner);
if (!owner) {
return;
}
const leadName = runtimeHelpers.inferLeadName(context.paths);
if (isSameTaskMember(owner, comment.author, leadName)) {
return;
}
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
messages.sendMessage(context, {
member: owner,
from: normalizeActorName(comment.author) || leadName,
text: buildCommentNotificationMessage(context, task, comment),
summary: `Comment on #${task.displayId || task.id}`,
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
});
}
function createTask(context, input) {
const task = taskStore.createTask(context.paths, input);
if (input && input.notifyOwner !== false) {
@ -152,6 +212,11 @@ function addTaskComment(context, taskId, flags) {
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
});
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
inserted: result.inserted,
notifyOwner: flags.notifyOwner,
});
return {
commentId: result.comment.id,
taskId: result.task.id,

View file

@ -382,6 +382,77 @@ describe('agent-teams-controller API', () => {
expect(rows[0].attachments[0].filename).toBe('note.txt');
});
it('wakes task owner on regular comment from another member', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false });
const commented = controller.tasks.addTaskComment(task.id, {
from: 'alice',
text: 'I found the root cause.',
});
expect(commented.task.comments.at(-1).text).toBe('I found the root cause.');
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].summary).toContain(`#${task.displayId}`);
expect(rows[0].text).toContain('I found the root cause.');
expect(rows[0].leadSessionId).toBe('lead-session-1');
});
it('does not wake owner for self-comments and clears user clarification when user replies', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Need product input',
owner: 'bob',
needsClarification: 'user',
notifyOwner: false,
});
controller.tasks.addTaskComment(task.id, {
from: 'bob',
text: 'Starting to investigate.',
});
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
expect(fs.existsSync(ownerInboxPath)).toBe(false);
const replied = controller.tasks.addTaskComment(task.id, {
from: 'user',
text: 'Please use the safer option.',
});
expect(replied.task.needsClarification).toBeUndefined();
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.needsClarification).toBeUndefined();
const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].text).toContain('Please use the safer option.');
});
it('wakes lead owner on comment from another member', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Lead-owned task',
owner: 'team-lead',
notifyOwner: false,
});
controller.tasks.addTaskComment(task.id, {
from: 'alice',
text: 'Need your decision here.',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe('alice');
expect(rows[0].text).toContain('Need your decision here.');
});
it('moves review back to pending+needsFix and notifies owner on requestChanges', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -151,6 +151,11 @@ describe('agent-teams-mcp tools', () => {
const commentId = commented.commentId;
expect(commentId).toBeTruthy();
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(ownerInbox.at(-1).summary).toContain(`#${createdTask.displayId}`);
expect(ownerInbox.at(-1).text).toContain('Need one more check');
const attachment = parseJsonToolResult(
await getTool('task_attach_comment_file').execute({
claudeDir,

View file

@ -281,6 +281,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
for (let i = 0; i < newMessages.length; i++) {
const msg = newMessages[i];
if ((msg.to ?? '').trim() !== 'user') continue;
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
// Skip internal coordination noise
@ -454,8 +455,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
);
}
// Auto-relay ONLY lead-inbox changes into the live lead process.
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
// Relay inbox changes into active runtime recipients.
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
const match = /^inboxes\/(.+)\.json$/.exec(detail);
if (match && teamDataService) {
@ -463,8 +463,11 @@ function wireFileWatcherEvents(context: ServiceContext): void {
void teamDataService
.getLeadMemberName(teamName)
.then((leadName) => {
if (!leadName || inboxName !== leadName) return;
return teamProvisioningService.relayLeadInboxMessages(teamName);
if (!leadName) return;
if (inboxName === leadName) {
return teamProvisioningService.relayLeadInboxMessages(teamName);
}
return teamProvisioningService.relayMemberInboxMessages(teamName, inboxName);
})
.catch((e: unknown) =>
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)

View file

@ -1143,13 +1143,12 @@ async function handleSendMessage(
from: payload.from,
});
// Best-effort: if team is alive and recipient is a teammate (not lead),
// also forward via the live lead process so in-process teammates receive it.
// Best-effort live relay so active processes see the inbox row promptly.
if (!isLeadRecipient && isAlive) {
try {
await provisioning.forwardUserDmToTeammate(tn, memberName, baseText, payload.summary);
await provisioning.relayMemberInboxMessages(tn, memberName);
} catch (e: unknown) {
logger.warn(`Failed to forward user DM to teammate "${memberName}" via lead: ${String(e)}`);
logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
}
}

View file

@ -954,58 +954,6 @@ export class TeamDataService {
...(attachments && attachments.length > 0 ? { attachments } : {}),
} as TaskComment);
try {
const [tasks, config] = await Promise.all([
this.taskReader.getTasks(teamName),
this.configReader.getConfig(teamName).catch(() => null),
]);
const task = addResult.task ?? tasks.find((t) => t.id === taskId);
const leadName = this.resolveLeadNameFromConfig(config);
const owner = task?.owner?.trim() || null;
// Auto-clear needsClarification: "user" on UI comment
// UI comments always have author "user" (TeamTaskWriter default)
if (task?.needsClarification === 'user') {
controller.tasks.setNeedsClarification(taskId, null);
}
if (task && owner && !this.isLeadOwner(owner, leadName)) {
// Notify non-lead task owner via inbox (lead → member message)
const parts = [
`Comment on task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`,
`\n${AGENT_BLOCK_OPEN}`,
`Reply to this comment using MCP tool task_add_comment:`,
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "<your-name>" }`,
AGENT_BLOCK_CLOSE,
];
await this.sendMessage(teamName, {
member: owner,
from: leadName,
text: parts.join('\n'),
summary: `Comment on ${this.getTaskLabel(task)}`,
source: 'system_notification',
});
} else if (task && owner && this.isLeadOwner(owner, leadName)) {
// Notify lead about user's comment on their own task.
// Write to lead's inbox — relay delivers to stdin when process is alive.
const parts = [
`New comment from user on your task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`,
`\n${AGENT_BLOCK_OPEN}`,
`Reply to this comment using MCP tool task_add_comment:`,
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "${leadName}" }`,
AGENT_BLOCK_CLOSE,
];
await this.sendMessage(teamName, {
member: leadName,
from: 'user',
text: parts.join('\n'),
summary: `Comment on ${this.getTaskLabel(task)}`,
source: 'system_notification',
});
}
} catch {
// Notification is best-effort — don't fail comment save
}
return comment;
}

View file

@ -394,15 +394,17 @@ function buildTaskStatusProtocol(teamName: string): string {
a) Owner finishes work on #X -> task_complete #X
b) Reviewer accepts -> review_approve #X
11. CLARIFICATION PROTOCOL (CRITICAL MANDATORY):
When you are blocked and need information to continue a task, you MUST do BOTH steps below skipping the MCP update breaks the task board:
a) STEP 1 FIRST, set the clarification flag with MCP tool task_set_clarification:
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
b) STEP 2 THEN, send a message to your team lead via SendMessage explaining what you need.
IMPORTANT: Always update the task board BEFORE sending the message. The flag is what makes the task board show "needs clarification" without it, your request is invisible on the board.
c) The flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
d) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
When you are blocked and need information to continue a task, you MUST do ALL steps below skipping the board update or comment breaks traceability:
a) STEP 1 FIRST, set the clarification flag with MCP tool task_set_clarification:
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
b) STEP 2 THEN, add a task comment describing exactly what you need:
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
c) STEP 3 THEN, send a message to your team lead via SendMessage so they notice it promptly.
IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board.
d) The flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
e) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
12. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting.
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
@ -484,7 +486,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`,
``,
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag) or SendMessage.`,
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage.`,
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
@ -1000,6 +1002,8 @@ export class TeamProvisioningService {
private readonly teamOpLocks = new Map<string, Promise<void>>();
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
private readonly memberInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedMemberInboxMessageIds = new Map<string, Set<string>>();
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
private helpOutputCache: string | null = null;
@ -1151,6 +1155,23 @@ export class TeamProvisioningService {
}
}
private getMemberRelayKey(teamName: string, memberName: string): string {
return `${teamName}:${memberName.trim()}`;
}
private armSilentTeammateForward(run: ProvisioningRun, teammateName: string): void {
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() };
if (run.silentUserDmForwardClearHandle) {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
run.silentUserDmForwardClearHandle = setTimeout(() => {
run.silentUserDmForward = null;
run.silentUserDmForwardClearHandle = null;
}, 60_000);
run.silentUserDmForwardClearHandle.unref();
}
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
@ -2379,17 +2400,7 @@ export class TeamProvisioningService {
return;
}
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() };
if (run.silentUserDmForwardClearHandle) {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
// Safety valve: if the CLI never emits a result message, don't stay in "silent" mode forever.
run.silentUserDmForwardClearHandle = setTimeout(() => {
run.silentUserDmForward = null;
run.silentUserDmForwardClearHandle = null;
}, 60_000);
run.silentUserDmForwardClearHandle.unref();
this.armSilentTeammateForward(run, teammateName);
const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null;
const internal = wrapInAgentBlock(
@ -2412,6 +2423,112 @@ export class TeamProvisioningService {
await this.sendMessageToTeam(teamName, message);
}
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
const relayKey = this.getMemberRelayKey(teamName, memberName);
const existing = this.memberInboxRelayInFlight.get(relayKey);
if (existing) {
return existing;
}
const work = (async (): Promise<number> => {
const runId = this.activeByTeam.get(teamName);
if (!runId) return 0;
const run = this.runs.get(runId);
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
if (!run.provisioningComplete) return 0;
const relayedIds = this.relayedMemberInboxMessageIds.get(relayKey) ?? new Set<string>();
let memberInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
try {
memberInboxMessages = await this.inboxReader.getMessagesFor(teamName, memberName);
} catch {
return 0;
}
const unread = memberInboxMessages
.filter((m): m is InboxMessage & { messageId: string } => {
if (m.read) return false;
if (typeof m.text !== 'string' || m.text.trim().length === 0) return false;
if (!this.hasStableMessageId(m)) return false;
return !relayedIds.has(m.messageId);
})
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
if (unread.length === 0) return 0;
const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text));
if (noiseUnread.length > 0) {
try {
await this.markInboxMessagesRead(teamName, memberName, noiseUnread);
} catch {
// best-effort
}
}
const actionableUnread = unread.filter((m) => !isInboxNoiseMessage(m.text));
if (actionableUnread.length === 0) return 0;
const MAX_RELAY = 10;
const batch = actionableUnread.slice(0, MAX_RELAY);
this.armSilentTeammateForward(run, memberName);
const message = [
`Relay inbox messages to teammate "${memberName}".`,
wrapInAgentBlock(
[
`Use the SendMessage tool with recipient="${memberName}".`,
`Forward each inbox item below as a teammate message, preserving task IDs and critical instructions.`,
`Do NOT send any message to recipient "user" for this relay turn.`,
`Do NOT add extra narration outside the SendMessage calls.`,
].join('\n')
),
``,
`Messages:`,
...batch.flatMap((m, idx) => {
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
return [
`${idx + 1}) From: ${m.from || 'unknown'}`,
` Timestamp: ${m.timestamp}`,
...(summaryLine ? [` ${summaryLine}`] : []),
` Text:`,
...m.text.split('\n').map((line) => ` ${line}`),
``,
];
}),
].join('\n');
try {
await this.sendMessageToTeam(teamName, message);
} catch {
return 0;
}
for (const m of batch) {
relayedIds.add(m.messageId);
}
this.relayedMemberInboxMessageIds.set(relayKey, this.trimRelayedSet(relayedIds));
try {
await this.markInboxMessagesRead(teamName, memberName, batch);
} catch {
// Best-effort: relay succeeded; marking read failed.
}
return batch.length;
})();
this.memberInboxRelayInFlight.set(relayKey, work);
try {
return await work;
} finally {
if (this.memberInboxRelayInFlight.get(relayKey) === work) {
this.memberInboxRelayInFlight.delete(relayKey);
}
}
}
/**
* Relay unread inbox messages addressed to the team lead into the live lead process.
*
@ -2763,24 +2880,22 @@ export class TeamProvisioningService {
}
/**
* Intercept SendMessage(to: "user") tool_use blocks from the lead's stream-json output.
* Intercept SendMessage tool_use blocks from the lead's stream-json output.
*
* Claude Code's internal teamContext may be lost after session resume (--resume), causing
* SendMessage to route messages to ~/.claude/teams/default/ instead of the real team.
* By capturing tool_use calls directly from stdout, we persist them to sentMessages.json
* under the correct team name ensuring the UI and OS notifications work correctly
* regardless of the internal teamContext state.
* SendMessage routing to drift away from our canonical team artifacts. By capturing tool_use
* calls directly from stdout, we persist a durable message row under the correct team name so
* Messages stays accurate even if Claude's own routing is flaky.
*/
private captureSendMessageToUser(run: ProvisioningRun, content: Record<string, unknown>[]): void {
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
for (const part of content) {
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
const input = part.input;
if (!input || typeof input !== 'object') continue;
const inp = input as Record<string, unknown>;
// Only capture messages addressed to the human user
const recipient = typeof inp.recipient === 'string' ? inp.recipient : '';
if (recipient !== 'user') continue;
if (!recipient.trim()) continue;
const msgContent = typeof inp.content === 'string' ? inp.content : '';
if (msgContent.trim().length === 0) continue;
@ -2795,10 +2910,10 @@ export class TeamProvisioningService {
const msg: InboxMessage = {
from: leadName,
to: 'user',
to: recipient,
text: cleanContent,
timestamp: nowIso(),
read: false,
read: recipient !== 'user',
summary:
(summary || cleanContent).length > 60
? (summary || cleanContent).slice(0, 57) + '...'
@ -2816,7 +2931,7 @@ export class TeamProvisioningService {
});
logger.debug(
`[${run.teamName}] Captured SendMessage→user from stdout: ${cleanContent.slice(0, 100)}`
`[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}`
);
}
}
@ -2947,12 +3062,13 @@ export class TeamProvisioningService {
return Array.isArray(inner) ? (inner as Record<string, unknown>[]) : null;
})();
const hasSendMessageToUser = (content ?? []).some((part) => {
const hasCapturedSendMessage = (content ?? []).some((part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
const input = part.input;
if (!input || typeof input !== 'object') return false;
return (input as Record<string, unknown>).recipient === 'user';
const recipient = (input as Record<string, unknown>).recipient;
return typeof recipient === 'string' && recipient.trim().length > 0;
});
const textParts = (content ?? [])
@ -2988,12 +3104,12 @@ export class TeamProvisioningService {
}
} else if (run.provisioningComplete) {
// Push each assistant text block as a separate live message (per-message pattern).
// When the same assistant message includes SendMessage(to:"user"), skip text —
// captureSendMessageToUser() handles it separately.
// When the same assistant message includes SendMessage(...), skip text —
// captureSendMessages() handles the visible outbound message separately.
if (
!run.silentUserDmForward &&
!run.suppressPostCompactReminderOutput &&
!hasSendMessageToUser
!hasCapturedSendMessage
) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
@ -3003,7 +3119,7 @@ export class TeamProvisioningService {
} else {
// Pre-ready: also push to live cache so Messages shows early narration
// once team:getData becomes readable. The banner still uses provisioningOutputParts.
if (!run.silentUserDmForward && !hasSendMessageToUser) {
if (!run.silentUserDmForward && !hasCapturedSendMessage) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
this.pushLiveLeadTextMessage(run, cleanText);
@ -3029,14 +3145,11 @@ export class TeamProvisioningService {
}
}
// Capture SendMessage(to: "user") tool_use blocks from assistant output.
// Claude Code's internal teamContext may route to "default" instead of the real team
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
// from stdout and persist them to sentMessages.json under the correct team name,
// ensuring the UI and notifications show the right team.
// Works in both pre-ready and post-ready phases so provisioning-time user DMs are captured.
// Capture SendMessage tool_use blocks from assistant output.
// Works in both pre-ready and post-ready phases so outbound runtime messages
// are visible in our team message artifacts even if Claude's own routing drifts.
if (!run.silentUserDmForward) {
this.captureSendMessageToUser(run, content ?? []);
this.captureSendMessages(run, content ?? []);
}
// Extract context window usage from message.usage for real-time tracking.
@ -3947,6 +4060,16 @@ export class TeamProvisioningService {
this.activeByTeam.delete(run.teamName);
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
if (key.startsWith(`${run.teamName}:`)) {
this.memberInboxRelayInFlight.delete(key);
}
}
for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) {
if (key.startsWith(`${run.teamName}:`)) {
this.relayedMemberInboxMessageIds.delete(key);
}
}
this.liveLeadProcessMessages.delete(run.teamName);
// Dismiss any pending tool approvals for this run
if (run.pendingApprovals.size > 0) {

View file

@ -12,11 +12,13 @@ export const UnreadCommentsBadge = ({
if (totalCount === 0) return null;
return (
<span className="relative inline-flex items-center gap-0.5 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0 text-[10px] font-medium text-[var(--color-text-muted)]">
<span
className={`relative inline-flex items-center gap-0.5 rounded-full bg-[var(--color-surface-raised)] py-0 text-[10px] font-medium text-[var(--color-text-muted)] ${unreadCount > 0 ? 'mr-1 pl-1.5 pr-2' : 'px-1.5'}`}
>
<MessageSquare size={10} />
{totalCount}
{unreadCount > 0 && (
<span className="absolute -right-2.5 -top-1.5 flex size-3.5 items-center justify-center rounded-full bg-blue-500 text-[8px] font-bold leading-none text-white">
<span className="absolute -top-1 right-0 flex h-3 min-w-[12px] translate-x-[calc(50%-4px)] items-center justify-center rounded-full bg-blue-500 px-0.5 text-[8px] font-bold leading-none text-white">
{unreadCount}
</span>
)}

View file

@ -357,7 +357,7 @@ export const ActivityTimeline = ({
sessionSeparator = (
<div
className="flex items-center gap-3"
style={{ paddingTop: 90, paddingBottom: 90 }}
style={{ paddingTop: 45, paddingBottom: 45 }}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">

View file

@ -39,6 +39,7 @@ export interface LeadThoughtGroup {
* an official message (SendMessage, direct reply, inbox, etc.).
*/
export function isLeadThought(msg: InboxMessage): boolean {
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
if (msg.source === 'lead_session') return true;
if (msg.source === 'lead_process') return true;
return false;
@ -362,7 +363,10 @@ const LeadThoughtItem = ({
</div>
)}
<div className="group/thought relative flex text-[11px]">
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
<div
className="min-w-0 flex-1 [&>span>div>div>div]:py-2"
style={{ color: CARD_TEXT_LIGHT }}
>
<span
onClickCapture={
onTaskIdClick

View file

@ -352,7 +352,7 @@ export const KanbanTaskCard = ({
<Button
variant="outline"
size="sm"
className="gap-1"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Complete task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
@ -370,7 +370,7 @@ export const KanbanTaskCard = ({
<Button
variant="outline"
size="sm"
className="gap-1"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Complete task ${task.id}`}
onClick={(e) => {
e.stopPropagation();

View file

@ -399,7 +399,12 @@ export const MessageComposer = ({
</div>
);
}
return filtered.map((m) => {
const sorted = [...filtered].sort((a, b) => {
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
return bIsLead - aIsLead;
});
return sorted.map((m) => {
const resolvedColor = colorMap.get(m.name);
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const isSelected = m.name === recipient;

View file

@ -140,9 +140,9 @@ const diffSpecificTheme = EditorView.theme({
fontSize: '12px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--color-text)',
backgroundColor: 'var(--color-surface-raised)',
border: '1px solid var(--color-border)',
color: 'var(--diff-merge-undo-color)',
backgroundColor: 'var(--diff-merge-undo-bg)',
border: '1px solid var(--diff-merge-undo-border)',
'&:hover': { backgroundColor: 'var(--diff-merge-undo-hover-bg)' },
'& kbd': { fontSize: '10px', color: 'var(--color-text-muted)', marginLeft: '4px' },
},

View file

@ -140,7 +140,7 @@ export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
<div className="space-y-4 p-4">
{nonErrorSnippets.map((snippet, index) => (
<SnippetDiffView
key={snippet.toolUseId}
key={`${snippet.toolUseId}-${index}`}
snippet={snippet}
index={index}
fileName={file.relativePath}

View file

@ -85,6 +85,9 @@
--diff-cm-changed-bg: #1a3a1a;
--diff-cm-deleted-bg: #241517;
/* CodeMirror merge buttons */
--diff-merge-undo-color: var(--color-text);
--diff-merge-undo-bg: var(--color-surface-raised);
--diff-merge-undo-border: var(--color-border);
--diff-merge-undo-hover-bg: rgba(255, 255, 255, 0.1);
--diff-merge-keep-color: #3fb950;
--diff-merge-keep-bg: rgba(46, 160, 67, 0.25);
@ -335,7 +338,10 @@
--diff-cm-changed-bg: rgba(34, 197, 94, 0.14);
--diff-cm-deleted-bg: rgba(239, 68, 68, 0.07);
/* CodeMirror merge buttons - Light mode */
--diff-merge-undo-hover-bg: rgba(0, 0, 0, 0.08);
--diff-merge-undo-color: #991b1b;
--diff-merge-undo-bg: rgba(239, 68, 68, 0.08);
--diff-merge-undo-border: rgba(239, 68, 68, 0.3);
--diff-merge-undo-hover-bg: rgba(239, 68, 68, 0.15);
--diff-merge-keep-color: #15803d;
--diff-merge-keep-bg: rgba(34, 197, 94, 0.15);
--diff-merge-keep-border: rgba(34, 197, 94, 0.4);

View file

@ -154,6 +154,7 @@ describe('ipc teams handlers', () => {
sendMessageToTeam: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true),
relayLeadInboxMessages: vi.fn(async () => 0),
relayMemberInboxMessages: vi.fn(async () => 0),
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
getAliveTeams: vi.fn(() => ['my-team']),
getLeadActivityState: vi.fn(() => 'idle'),

View file

@ -274,6 +274,36 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
});
it('captures SendMessage(to:team-lead) without rendering duplicate assistant thought text', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{ type: 'text', text: 'Forwarding the clarification request now.' },
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-lead',
content: 'Need clarification on #abcd1234',
summary: 'Clarification request',
},
},
],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].to).toBe('team-lead');
expect(live[0].text).toBe('Need clarification on #abcd1234');
expect(live[0].source).toBe('lead_process');
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
});
it('post-ready path also uses the unified helper', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');

View file

@ -216,6 +216,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
expect(prompt).toContain('you MUST do ALL steps below');
expect(prompt).toContain('STEP 2 — THEN, add a task comment describing exactly what you need');
expect(prompt).toContain('STEP 3 — THEN, send a message to your team lead via SendMessage');
expect(prompt).toContain('use task_briefing as your compact queue view');
expect(prompt).toContain('Use task_get when you need the full task context before starting a pending/needsFix task');
expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
@ -278,6 +281,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
expect(prompt).toContain('reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage');
expect(prompt).toContain('Your FIRST action: call MCP tool task_briefing');
expect(prompt).toContain('resume/finish those first');
expect(prompt).toContain('Call task_get only if you need more context than task_briefing already gave you');

View file

@ -105,6 +105,10 @@ function seedLeadInbox(teamName: string, messages: unknown[]): void {
hoisted.files.set(`/mock/teams/${teamName}/inboxes/team-lead.json`, JSON.stringify(messages));
}
function seedMemberInbox(teamName: string, memberName: string, messages: unknown[]): void {
hoisted.files.set(`/mock/teams/${teamName}/inboxes/${memberName}.json`, JSON.stringify(messages));
}
function attachAliveRun(
service: TeamProvisioningService,
teamName: string,
@ -293,4 +297,30 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(writeSpy).toHaveBeenCalledTimes(0);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
it('relays unread teammate inbox messages through the live team process', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'team-lead',
text: 'Comment on task #abcd1234 "Investigate":\n\nPlease retry with logging enabled.',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
summary: 'Comment on #abcd1234',
messageId: 'm-alice-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
expect(relayed).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('"type":"user"');
expect(payload).toContain('recipient=\\"alice\\"');
expect(payload).toContain('Please retry with logging enabled.');
});
});

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { isLeadThought } from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup';
describe('LeadThoughtsGroup', () => {
it('does not classify outbound runtime messages with recipients as lead thoughts', () => {
expect(
isLeadThought({
from: 'team-lead',
to: 'alice',
text: 'Please check task #abcd1234',
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_process',
})
).toBe(false);
});
});