;
+ // Only reset stall timer on messages that represent actual API progress
+ // (assistant response or result). System messages like retry attempts
+ // (type=system, subtype=attempt) are informational — the CLI is still
+ // waiting for the API and the user should see the stall warning.
+ const msgType = msg.type;
+ if (msgType === 'assistant' || msgType === 'result') {
+ run.lastStdoutReceivedAt = Date.now();
+ if (run.stallWarningIndex != null) {
+ run.provisioningOutputParts.splice(run.stallWarningIndex, 1);
+ run.stallWarningIndex = null;
+ if (run.preStallMessage != null) {
+ run.progress.message = run.preStallMessage;
+ run.preStallMessage = null;
+ delete run.progress.messageSeverity;
+ }
+ }
+ }
this.handleStreamJsonMessage(run, msg);
} catch {
// Not valid JSON — check for auth failure in raw text output
@@ -2859,7 +2912,11 @@ export class TeamProvisioningService {
request,
lastLogProgressAt: 0,
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
+ lastStdoutReceivedAt: 0,
stallCheckHandle: null,
+ stallWarningIndex: null,
+ preStallMessage: null,
+ lastRetryAt: 0,
apiErrorWarningEmitted: false,
waitingTasksSince: null,
provisioningComplete: false,
@@ -2885,7 +2942,9 @@ export class TeamProvisioningService {
pendingPostCompactReminder: false,
postCompactReminderInFlight: false,
suppressPostCompactReminderOutput: false,
- memberSpawnStatuses: new Map(),
+ memberSpawnStatuses: new Map(
+ request.members.map((m) => [m.name, { status: 'waiting' as const, updatedAt: nowIso() }])
+ ),
progress: {
runId,
teamName: request.teamName,
@@ -3015,6 +3074,7 @@ export class TeamProvisioningService {
// Reset AFTER spawn — not at run init — because async operations (buildProvisioningEnv,
// writeConfigFile) between init and spawn can take seconds, causing false stall warnings.
run.lastDataReceivedAt = Date.now();
+ run.lastStdoutReceivedAt = Date.now();
this.startStallWatchdog(run);
// Filesystem-based progress monitor: actively polls team files instead
@@ -3284,7 +3344,11 @@ export class TeamProvisioningService {
request: syntheticRequest,
lastLogProgressAt: 0,
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
+ lastStdoutReceivedAt: 0,
stallCheckHandle: null,
+ stallWarningIndex: null,
+ preStallMessage: null,
+ lastRetryAt: 0,
apiErrorWarningEmitted: false,
waitingTasksSince: null,
provisioningComplete: false,
@@ -3310,7 +3374,9 @@ export class TeamProvisioningService {
pendingPostCompactReminder: false,
postCompactReminderInFlight: false,
suppressPostCompactReminderOutput: false,
- memberSpawnStatuses: new Map(),
+ memberSpawnStatuses: new Map(
+ expectedMembers.map((name) => [name, { status: 'waiting' as const, updatedAt: nowIso() }])
+ ),
progress: {
runId,
teamName: request.teamName,
@@ -3442,6 +3508,7 @@ export class TeamProvisioningService {
// Reset AFTER spawn — not at run init — because async operations between init
// and spawn can take seconds, causing false stall warnings.
run.lastDataReceivedAt = Date.now();
+ run.lastStdoutReceivedAt = Date.now();
this.startStallWatchdog(run);
// For launch, skip the filesystem monitor — files (config, inboxes, tasks)
@@ -3575,12 +3642,13 @@ export class TeamProvisioningService {
}
/**
- * Best-effort: forward a user-written DM to a teammate via the live lead process.
- * This covers cases where teammates don't automatically respond to inbox JSON,
- * and only react to Claude Code internal SendMessage routing.
+ * UNUSED (2026-03-23): teammates read their own inbox files directly via fs.watch,
+ * so forwarding through the lead is unnecessary. Kept for reference — the prompt
+ * pattern here ("MUST: ask teammate to reply back to user") was a useful finding
+ * that informed the direct inbox approach.
*
- * Note: We suppress the lead's textual output for this injected turn to avoid
- * confusing lead responses like "No action needed."
+ * Original purpose: forward a user DM to a teammate by injecting a relay turn
+ * into the lead's stdin and suppressing the lead's textual output.
*/
async forwardUserDmToTeammate(
teamName: string,
@@ -3683,19 +3751,17 @@ export class TeamProvisioningService {
const rememberedRelayIds = this.rememberPendingInboxRelayCandidates(run, memberName, batch);
const message = [
- `Relay inbox messages to teammate "${memberName}".`,
+ `Inbox relay (internal) — forward to "${memberName}".`,
wrapInAgentBlock(
[
- `Use the SendMessage tool with recipient="${memberName}".`,
- `Forward each inbox item below as a teammate message, preserving task IDs and critical instructions.`,
- `If an inbox item is marked Source: system_notification, treat it as an automated runtime notification.`,
- `Forward that automated notification exactly once; do NOT send an additional paraphrased/manual follow-up for the same assignment/review/comment in this relay turn unless you truly need extra non-redundant context.`,
- `Do NOT send any message to recipient "user" for this relay turn.`,
- `Do NOT add extra narration outside the SendMessage calls.`,
+ `CRITICAL: Do NOT send any message to recipient "user" for this relay turn. The ONLY valid recipient is "${memberName}".`,
+ `Use the SendMessage tool with recipient="${memberName}" to forward each inbox item below.`,
+ `Preserve task IDs and critical instructions. Do NOT add extra narration outside the SendMessage calls.`,
+ `If an inbox item is marked Source: system_notification, forward that notification exactly once without paraphrasing.`,
].join('\n')
),
``,
- `Messages:`,
+ `Messages to relay (DO NOT respond to user directly):`,
...batch.flatMap((m, idx) => {
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
const crossTeamMeta =
@@ -4524,6 +4590,21 @@ export class TeamProvisioningService {
continue;
}
+ // Suppress SendMessage(to="user") during member_inbox_relay.
+ // Context: when relaying inbox messages, the lead sometimes ignores the relay
+ // instruction and responds to the user directly instead of forwarding to the
+ // target teammate. This filter prevents that wrong response from appearing
+ // in the UI and being persisted to sentMessages.json.
+ // Note: teammate DM relay is currently disabled (see teams.ts handleSendMessage
+ // and index.ts FileWatcher). This guard is kept as safety net in case relay
+ // is re-enabled in the future.
+ if (recipient === 'user' && run.silentUserDmForward?.mode === 'member_inbox_relay') {
+ logger.debug(
+ `[${run.teamName}] Suppressed SendMessage→user during member_inbox_relay to "${run.silentUserDmForward.target}"`
+ );
+ continue;
+ }
+
const relayOfMessageId =
recipient !== 'user'
? this.consumePendingInboxRelayCandidate(
@@ -5124,6 +5205,33 @@ export class TeamProvisioningService {
);
}
}
+
+ // Show API retry attempts in Live output so the user knows what's happening
+ if (sub === 'api_retry') {
+ const attempt = typeof msg.attempt === 'number' ? msg.attempt : '?';
+ const maxRetries = typeof msg.max_retries === 'number' ? msg.max_retries : '?';
+ const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
+ const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : undefined;
+ const retryDelay = typeof msg.retry_delay_ms === 'number' ? msg.retry_delay_ms : undefined;
+
+ // Use CLI's own error label (e.g. "rate limit") with status code
+ const statusLabel = errorLabel
+ ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
+ : `error ${errorStatus ?? 'unknown'}`;
+ const delayLabel = retryDelay ? ` — next retry in ${Math.round(retryDelay / 1000)}s` : '';
+ const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${delayLabel}`;
+
+ if (!run.provisioningComplete) {
+ run.lastRetryAt = Date.now();
+ run.progress = {
+ ...run.progress,
+ updatedAt: nowIso(),
+ message: retryText,
+ messageSeverity: 'error' as const,
+ };
+ run.onProgress(run.progress);
+ }
+ }
}
// Catch-all: detect API errors in unrecognised message types.
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
index 275058b6..24a82832 100644
--- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
@@ -227,12 +227,19 @@ export const TaskCommentsSection = ({
: '',
].join(' ')}
style={
- !comment.type || comment.type === 'regular'
+ comment.author === 'system'
? {
- backgroundColor:
- index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
+ backgroundColor: 'var(--system-activity-bg)',
+ borderTop: '1px solid var(--system-activity-border)',
+ borderBottom: '1px solid var(--system-activity-border)',
+ borderLeft: '3px solid var(--system-activity-accent)',
}
- : undefined
+ : !comment.type || comment.type === 'regular'
+ ? {
+ backgroundColor:
+ index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
+ }
+ : undefined
}
>
@@ -242,7 +249,7 @@ export const TaskCommentsSection = ({
{comment.type === 'review_approved' ? (