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;
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:
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, 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.
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" }
d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
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);
});
});