diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 950bf628..03eab37d 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -62,7 +62,7 @@ const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000;
// const CLI_PREPARE_TIMEOUT_MS = 10000;
const PROBE_CACHE_TTL_MS = 10 * 60_000;
-const PREFLIGHT_TIMEOUT_MS = 30000;
+const PREFLIGHT_TIMEOUT_MS = 60000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const FS_MONITOR_POLL_MS = 2000;
@@ -70,8 +70,20 @@ const TASK_WAIT_FALLBACK_MS = 15_000;
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
-const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else';
-const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const;
+const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
+const PREFLIGHT_PING_ARGS = [
+ '-p',
+ PREFLIGHT_PING_PROMPT,
+ '--output-format',
+ 'text',
+ '--model',
+ 'haiku',
+ '--max-turns',
+ '1',
+ '--max-budget-usd',
+ '0.05',
+ '--no-session-persistence',
+] as const;
const PREFLIGHT_EXPECTED = 'PONG';
type TeamsBaseLocation = 'configured' | 'default';
@@ -1008,8 +1020,23 @@ interface CachedProbeResult {
}
let cachedProbeResult: CachedProbeResult | null = null;
-let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null =
- null;
+let probeInFlight: Promise<{
+ claudePath: string;
+ authSource: ProvisioningAuthSource;
+ warning?: string;
+} | null> | null = null;
+
+function isTransientProbeWarning(warning: string): boolean {
+ const lower = warning.toLowerCase();
+ return (
+ lower.includes('timeout running:') ||
+ lower.includes('did not complete') ||
+ lower.includes('timed out') ||
+ lower.includes('etimedout') ||
+ lower.includes('econnreset') ||
+ lower.includes('eai_again')
+ );
+}
export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
@@ -1046,7 +1073,9 @@ export class TeamProvisioningService {
const offsetRaw = query?.offset ?? 0;
const limitRaw = query?.limit ?? 100;
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
+ const limit = Number.isFinite(limitRaw)
+ ? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
+ : 100;
const total = run.claudeLogLines.length;
if (total === 0) {
@@ -1057,8 +1086,10 @@ export class TeamProvisioningService {
const oldestInclusive = Math.max(0, newestExclusive - limit);
const normalizeLine = (line: string): string => {
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
- if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length);
- if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length);
+ if (line.startsWith('[stdout] ') && line !== '[stdout]')
+ return line.slice('[stdout] '.length);
+ if (line.startsWith('[stderr] ') && line !== '[stderr]')
+ return line.slice('[stderr] '.length);
return line;
};
@@ -1201,7 +1232,8 @@ export class TeamProvisioningService {
async warmup(): Promise {
try {
- if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return;
+ if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS)
+ return;
const result = await this.getCachedOrProbeResult(process.cwd());
if (!result) return;
logger.info('CLI warmup completed');
@@ -1226,7 +1258,11 @@ export class TeamProvisioningService {
const ready = !warning || authSource !== 'none' || !isAuthFailure;
return {
ready,
- message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready',
+ message: ready
+ ? warnings.length > 0
+ ? 'CLI is ready to launch (see notes)'
+ : 'CLI is warmed up and ready to launch'
+ : warning || 'CLI is not ready',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
@@ -1267,7 +1303,10 @@ export class TeamProvisioningService {
return {
ready: true,
- message: 'CLI is warmed up and ready to launch',
+ message:
+ warnings.length > 0
+ ? 'CLI is ready to launch (see notes)'
+ : 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
@@ -1287,7 +1326,11 @@ export class TeamProvisioningService {
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
const cached = this.getFreshCachedProbeResult();
if (cached) {
- return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning };
+ return {
+ claudePath: cached.claudePath,
+ authSource: cached.authSource,
+ warning: cached.warning,
+ };
}
if (probeInFlight) {
@@ -1300,12 +1343,20 @@ export class TeamProvisioningService {
const { env, authSource } = await this.buildProvisioningEnv();
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
- const result = { claudePath, authSource, ...(probe.warning ? { warning: probe.warning } : {}) };
+ const result = {
+ claudePath,
+ authSource,
+ ...(probe.warning ? { warning: probe.warning } : {}),
+ };
- if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
+ const shouldCache =
+ !probe.warning ||
+ (!this.isAuthFailureWarning(probe.warning) && !isTransientProbeWarning(probe.warning));
+
+ if (shouldCache) {
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
} else {
- // Don't pin auth failures in cache — user may log in externally and retry.
+ // Don't pin auth failures / transient failures in cache — user may fix and retry.
cachedProbeResult = null;
}
@@ -2819,7 +2870,6 @@ export class TeamProvisioningService {
'team-lead';
const leadMsg: InboxMessage = {
from: leadName,
- to: 'user',
text: cleanText,
timestamp: nowIso(),
read: true,
@@ -2831,7 +2881,10 @@ export class TeamProvisioningService {
run.leadTextPushedInCurrentTurn = true;
const now = Date.now();
- if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) {
+ if (
+ now - run.lastLeadTextEmitMs >=
+ TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS
+ ) {
run.lastLeadTextEmitMs = now;
this.teamChangeEmitter?.({
type: 'inbox',
@@ -3126,9 +3179,7 @@ export class TeamProvisioningService {
| undefined;
const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto';
const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null;
- const tokenInfo = preTokens
- ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)`
- : '';
+ const tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : '';
const compactMsg: InboxMessage = {
from: 'system',
@@ -3160,7 +3211,12 @@ export class TeamProvisioningService {
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise {
// Guard: must be set synchronously BEFORE any await to prevent
// double-invocation from filesystem monitor + stream-json racing.
- if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed')
+ if (
+ run.provisioningComplete ||
+ run.cancelRequested ||
+ run.processKilled ||
+ run.progress.state === 'failed'
+ )
return;
// Prevent false "ready" when auth failure was printed as assistant text or logs
@@ -3172,7 +3228,11 @@ export class TeamProvisioningService {
.filter(Boolean)
.join('\n')
.trim();
- if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
+ if (
+ preCompleteText &&
+ this.hasApiError(preCompleteText) &&
+ !this.isAuthFailureWarning(preCompleteText)
+ ) {
this.failProvisioningWithApiError(run, preCompleteText);
return;
}
@@ -3976,7 +4036,9 @@ export class TeamProvisioningService {
if (membersRaw.length > 0) {
const teammateNames = membersRaw
.map((m) => (typeof m.name === 'string' ? m.name.trim() : ''))
- .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
+ .filter(
+ (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
+ );
const keepName = createCliAutoSuffixNameGuard(teammateNames);
const nextMembers: Record[] = [];
@@ -4015,7 +4077,9 @@ export class TeamProvisioningService {
const activeNames = metaMembers
.filter((m) => !m.removedAt)
.map((m) => m.name.trim())
- .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
+ .filter(
+ (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
+ );
const keepName = createCliAutoSuffixNameGuard(activeNames);
const removedFromMeta: string[] = [];
@@ -4042,7 +4106,9 @@ export class TeamProvisioningService {
nextMeta
.filter((m) => !m.removedAt)
.map((m) => m.name.trim())
- .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user')
+ .filter(
+ (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
+ )
);
}
} catch {
@@ -4658,7 +4724,13 @@ export class TeamProvisioningService {
[...PREFLIGHT_PING_ARGS],
cwd,
env,
- PREFLIGHT_TIMEOUT_MS
+ PREFLIGHT_TIMEOUT_MS,
+ {
+ resolveOnOutputMatch: ({ stdout, stderr }) => {
+ const combined = `${stdout}\n${stderr}`.trim();
+ return /\bPONG\b/i.test(combined);
+ },
+ }
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -4705,7 +4777,7 @@ export class TeamProvisioningService {
}
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
- const isPong = pongCandidate.toUpperCase() === PREFLIGHT_EXPECTED;
+ const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate);
if (!isPong) {
return {
warning:
@@ -4730,7 +4802,15 @@ export class TeamProvisioningService {
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
- timeoutMs: number
+ timeoutMs: number,
+ options?: {
+ /**
+ * Optional early success predicate. If this returns true based on
+ * buffered stdout/stderr, the probe resolves immediately (and the process
+ * is best-effort terminated) instead of waiting for `close`.
+ */
+ resolveOnOutputMatch?: (ctx: { stdout: string; stderr: string }) => boolean;
+ }
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawnCli(claudePath, args, {
@@ -4738,26 +4818,52 @@ export class TeamProvisioningService {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
- const stdoutChunks: Buffer[] = [];
- const stderrChunks: Buffer[] = [];
+ let stdoutText = '';
+ let stderrText = '';
+ let settled = false;
const timeoutHandle = setTimeout(() => {
+ settled = true;
killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
}, timeoutMs);
- child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
- child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
+ const maybeResolveEarly = (): void => {
+ if (settled) return;
+ if (!options?.resolveOnOutputMatch) return;
+ const ctx = { stdout: stdoutText.trim(), stderr: stderrText.trim() };
+ if (!options.resolveOnOutputMatch(ctx)) return;
+
+ settled = true;
+ clearTimeout(timeoutHandle);
+ // If the process printed the match but hangs during teardown, don't
+ // block the UI; terminate best-effort and resolve.
+ killProcessTree(child);
+ resolve({ exitCode: 0, stdout: ctx.stdout, stderr: ctx.stderr });
+ };
+
+ child.stdout?.on('data', (chunk: Buffer) => {
+ stdoutText += chunk.toString('utf8');
+ maybeResolveEarly();
+ });
+ child.stderr?.on('data', (chunk: Buffer) => {
+ stderrText += chunk.toString('utf8');
+ maybeResolveEarly();
+ });
child.once('error', (error) => {
+ if (settled) return;
+ settled = true;
clearTimeout(timeoutHandle);
reject(error);
});
child.once('close', (exitCode) => {
+ if (settled) return;
+ settled = true;
clearTimeout(timeoutHandle);
resolve({
exitCode,
- stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(),
- stderr: Buffer.concat(stderrChunks).toString('utf8').trim(),
+ stdout: stdoutText.trim(),
+ stderr: stderrText.trim(),
});
});
});
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx
index 39dc5742..0a20eaa5 100644
--- a/src/renderer/components/team/ClaudeLogsSection.tsx
+++ b/src/renderer/components/team/ClaudeLogsSection.tsx
@@ -3,14 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
+import { useStore } from '@renderer/store';
import { Search, Terminal, X } from 'lucide-react';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { CliLogsRichView } from './CliLogsRichView';
-import {
- ClaudeLogsFilterPopover,
- DEFAULT_CLAUDE_LOGS_FILTER,
-} from './ClaudeLogsFilterPopover';
+import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
import type { TeamClaudeLogsResponse } from '@shared/types';
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
@@ -108,7 +106,10 @@ function filterStreamJsonText(
return null;
};
- const writeBlocks = (parsed: Record, blocks: AssistantContentBlock[]): Record => {
+ const writeBlocks = (
+ parsed: Record,
+ blocks: AssistantContentBlock[]
+ ): Record => {
if (Array.isArray(parsed.content)) {
return { ...parsed, content: blocks };
}
@@ -188,6 +189,7 @@ function filterStreamJsonText(
}
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
+ const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [data, setData] = useState({ lines: [], total: 0, hasMore: false });
const [pending, setPending] = useState(null);
@@ -233,12 +235,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
useEffect(() => {
let cancelled = false;
- const computeNewCount = (committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse): number => {
+ const computeNewCount = (
+ committed: TeamClaudeLogsResponse,
+ latest: TeamClaudeLogsResponse
+ ): number => {
if (committed.lines.length === 0) return latest.lines.length;
const marker = committed.lines[0];
const idx = latest.lines.indexOf(marker);
if (idx >= 0) return idx;
- const diff = (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
+ const diff =
+ (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
return Math.max(0, diff);
};
@@ -331,8 +337,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
Showing {Math.min(data.total, visibleCount)} of{' '}
{data.total}
>
- ) : (
+ ) : isAlive ? (
'No logs yet.'
+ ) : (
+ 'Team is not running.'
)}
@@ -366,7 +374,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
-
+
{error ?
{error}
: null}
{!error && filteredText.trim().length > 0 ? (
- {loading ? 'Loading…' : 'No logs captured.'}
+ {loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}
) : null}
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
-
- No matching logs.
-
+ No matching logs.
) : null}
);
};
-
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
index 45d8ad17..ef1c24dc 100644
--- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx
+++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
@@ -26,7 +26,6 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
-import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge';
@@ -129,7 +128,16 @@ export const SendMessageDialog = ({
}
}
prevOpenRef.current = open;
- }, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]);
+ }, [
+ open,
+ defaultRecipient,
+ defaultText,
+ defaultChip,
+ quotedMessage,
+ lastResult,
+ textDraft,
+ chipDraft,
+ ]);
// Track whether auto-close is needed (avoid setState in render)
useEffect(() => {
@@ -381,7 +389,7 @@ export const SendMessageDialog = ({
) : null}
{textDraft.isSaved ? (
- Draft saved
+
+ Draft saved
+
) : null}
}
@@ -442,7 +452,6 @@ export const SendMessageDialog = ({
Shown as notification preview. Team lead also sees this for peer messages.
-
diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
index a25757a3..5bc12d5e 100644
--- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
@@ -7,7 +7,6 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
-import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
@@ -71,50 +70,45 @@ export const TaskCommentInput = ({
trimmed.length <= MAX_COMMENT_LENGTH &&
!addingComment;
- const addFiles = useCallback(
- (files: FileList | File[]) => {
- setAttachError(null);
- const fileArray = Array.from(files);
- for (const file of fileArray) {
- if (!ACCEPTED_TYPES.has(file.type)) {
- setAttachError(`Unsupported type: ${file.type}`);
- continue;
- }
- if (file.size > MAX_FILE_SIZE) {
- setAttachError(
- `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
- );
- continue;
- }
- const reader = new FileReader();
- reader.onload = () => {
- const result = reader.result as string;
- const base64 = result.split(',')[1];
- if (!base64) return;
- const id = crypto.randomUUID();
- setPendingAttachments((prev) => {
- if (prev.length >= MAX_ATTACHMENTS) {
- setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
- return prev;
- }
- return [
- ...prev,
- {
- id,
- filename: file.name,
- mimeType: file.type,
- base64Data: base64,
- previewUrl: result,
- size: file.size,
- },
- ];
- });
- };
- reader.readAsDataURL(file);
+ const addFiles = useCallback((files: FileList | File[]) => {
+ setAttachError(null);
+ const fileArray = Array.from(files);
+ for (const file of fileArray) {
+ if (!ACCEPTED_TYPES.has(file.type)) {
+ setAttachError(`Unsupported type: ${file.type}`);
+ continue;
}
- },
- []
- );
+ if (file.size > MAX_FILE_SIZE) {
+ setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
+ continue;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result as string;
+ const base64 = result.split(',')[1];
+ if (!base64) return;
+ const id = crypto.randomUUID();
+ setPendingAttachments((prev) => {
+ if (prev.length >= MAX_ATTACHMENTS) {
+ setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
+ return prev;
+ }
+ return [
+ ...prev,
+ {
+ id,
+ filename: file.name,
+ mimeType: file.type,
+ base64Data: base64,
+ previewUrl: result,
+ size: file.size,
+ },
+ ];
+ });
+ };
+ reader.readAsDataURL(file);
+ }
+ }, []);
const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
@@ -250,7 +244,7 @@ export const TaskCommentInput = ({
/>