feat: add Claude logs retrieval functionality

- Introduced TEAM_GET_CLAUDE_LOGS IPC channel for fetching buffered Claude CLI logs.
- Implemented handleGetClaudeLogs function to validate requests and retrieve logs with pagination support.
- Enhanced TeamProvisioningService to manage and store Claude log lines, including limits on stored logs.
- Added ClaudeLogsSection component to display logs in the UI, with support for pagination and real-time updates.
- Updated relevant types and API interfaces to accommodate new log retrieval features.
This commit is contained in:
iliya 2026-03-05 15:32:34 +02:00
parent 82bea01e0f
commit fdb52922fe
17 changed files with 553 additions and 87 deletions

View file

@ -14,6 +14,7 @@ import {
TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
@ -108,6 +109,8 @@ import type {
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
@ -195,6 +198,7 @@ export function initializeTeamHandlers(
export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_LIST, handleListTeams);
ipcMain.handle(TEAM_GET_DATA, handleGetData);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_LIST);
ipcMain.removeHandler(TEAM_GET_DATA);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_CREATE);
ipcMain.removeHandler(TEAM_LAUNCH);
@ -634,6 +639,39 @@ async function validateProvisioningRequest(
};
}
async function handleGetClaudeLogs(
_event: IpcMainInvokeEvent,
teamName: unknown,
query?: unknown
): Promise<IpcResult<TeamClaudeLogsResponse>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
let parsed: TeamClaudeLogsQuery | undefined;
if (query !== undefined) {
if (!query || typeof query !== 'object') {
return { success: false, error: 'query must be an object' };
}
const q = query as Record<string, unknown>;
parsed = {
offset: typeof q.offset === 'number' ? q.offset : undefined,
limit: typeof q.limit === 'number' ? q.limit : undefined,
};
}
return wrapTeamHandler('getClaudeLogs', async () => {
const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
return {
lines: data.lines,
total: data.total,
hasMore: data.hasMore,
updatedAt: data.updatedAt,
};
});
}
async function handleCreateTeam(
event: IpcMainInvokeEvent,
request: unknown

View file

@ -114,6 +114,14 @@ interface ProvisioningRun {
progress: TeamProvisioningProgress;
stdoutBuffer: string;
stderrBuffer: string;
/** Rolling buffer of CLI log lines (oldest -> newest). */
claudeLogLines: string[];
/** Carry buffer for stdout line splitting (CLI output). */
stdoutLogLineBuf: string;
/** Carry buffer for stderr line splitting (CLI output). */
stderrLogLineBuf: string;
/** ISO timestamp when the last CLI line was recorded. */
claudeLogsUpdatedAt?: string;
processKilled: boolean;
finalizingByTimeout: boolean;
cancelRequested: boolean;
@ -719,7 +727,8 @@ ${membersFooter}
function buildLaunchPrompt(
request: TeamLaunchRequest,
members: TeamCreateRequest['members'],
tasks: TeamTask[]
tasks: TeamTask[],
isResume: boolean
): string {
const membersBlock = buildMembersPrompt(members);
const userPromptBlock = request.prompt?.trim()
@ -828,7 +837,9 @@ ${memberSpawnInstructions}
? `Members:\n${membersBlock}`
: 'Members: (none — solo team lead)';
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
@ -967,6 +978,8 @@ interface CachedProbeResult {
let cachedProbeResult: CachedProbeResult | null = null;
export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
private readonly runs = new Map<string, ProvisioningRun>();
private readonly activeByTeam = new Map<string, string>();
private readonly teamOpLocks = new Map<string, Promise<void>>();
@ -983,6 +996,71 @@ export class TeamProvisioningService {
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
) {}
getClaudeLogs(
teamName: string,
query?: { offset?: number; limit?: number }
): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } {
const runId = this.activeByTeam.get(teamName);
if (!runId) {
return { lines: [], total: 0, hasMore: false };
}
const run = this.runs.get(runId);
if (!run) {
return { lines: [], total: 0, hasMore: false };
}
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 total = run.claudeLogLines.length;
if (total === 0) {
return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt };
}
const newestExclusive = Math.max(0, total - offset);
const oldestInclusive = Math.max(0, newestExclusive - limit);
const windowOldestToNewest = run.claudeLogLines.slice(oldestInclusive, newestExclusive);
const lines = windowOldestToNewest.reverse();
return {
lines,
total,
hasMore: oldestInclusive > 0,
updatedAt: run.claudeLogsUpdatedAt,
};
}
private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void {
const nowMs = Date.now();
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
const prefix = stream === 'stdout' ? '[stdout] ' : '[stderr] ';
if (stream === 'stdout') {
run.stdoutLogLineBuf += text;
const parts = run.stdoutLogLineBuf.split('\n');
run.stdoutLogLineBuf = parts.pop() ?? '';
for (const part of parts) {
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
run.claudeLogLines.push(prefix + normalized);
}
} else {
run.stderrLogLineBuf += text;
const parts = run.stderrLogLineBuf.split('\n');
run.stderrLogLineBuf = parts.pop() ?? '';
for (const part of parts) {
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
run.claudeLogLines.push(prefix + normalized);
}
}
if (run.claudeLogLines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) {
run.claudeLogLines.splice(
0,
run.claudeLogLines.length - TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT
);
}
}
/**
* Serializes operations per team name using promise-chaining.
* Same pattern as withInboxLock / withTaskLock.
@ -1189,6 +1267,67 @@ export class TeamProvisioningService {
);
}
private hasApiError(text: string): boolean {
return /api error:\s*\d{3}\b/i.test(text) || /invalid_request_error/i.test(text);
}
private sanitizeCliSnippet(text: string): string {
// Remove control characters that often show up as binary noise in CLI error payloads.
// Preserve newlines/tabs for readability.
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
}
private extractApiErrorSnippet(text: string): string | null {
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
if (!match || match.index === undefined) return null;
const start = Math.max(0, match.index - 200);
const end = Math.min(text.length, match.index + 4000);
const raw = text.slice(start, end).trim();
if (!raw) return null;
// Avoid breaking markdown fences if the payload contains ``` accidentally.
return this.sanitizeCliSnippet(raw).replace(/```/g, '``\\`');
}
private failProvisioningWithApiError(run: ProvisioningRun, source: string): void {
if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return;
if (run.progress.state === 'failed' || run.cancelRequested) return;
const combined = [
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
]
.filter(Boolean)
.join('\n')
.trim();
const snippet =
this.extractApiErrorSnippet(combined) ?? this.extractApiErrorSnippet(source) ?? null;
const status =
/api error:\s*(\d{3})\b/i.exec(combined)?.[1] ?? /api error:\s*(\d{3})\b/i.exec(source)?.[1];
const hint = run.isLaunch ? 'Launch' : 'Provisioning';
const statusLabel = status ? `API Error ${status}` : 'API Error';
if (snippet) {
run.provisioningOutputParts.push(
`**${hint} failed: ${statusLabel} detected**\n\n\`\`\`\n${snippet}\n\`\`\``
);
} else {
run.provisioningOutputParts.push(`**${hint} failed: ${statusLabel} detected**`);
}
const progress = updateProgress(run, 'failed', `${hint} failed — ${statusLabel}`, {
error: `Claude CLI reported ${statusLabel} during startup. The team was not started.`,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
run.processKilled = true;
run.cancelRequested = true;
run.child?.stdin?.end();
killProcessTree(run.child);
this.cleanupRun(run);
}
/**
* Detects auth failure keywords in stderr/stdout during provisioning.
* On first detection: kills process, waits, and respawns automatically.
@ -1250,6 +1389,10 @@ export class TeamProvisioningService {
// Reset buffers for fresh attempt
run.stdoutBuffer = '';
run.stderrBuffer = '';
run.claudeLogLines = [];
run.stdoutLogLineBuf = '';
run.stderrLogLineBuf = '';
run.claudeLogsUpdatedAt = undefined;
run.authFailureRetried = true;
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
@ -1362,6 +1505,7 @@ export class TeamProvisioningService {
let stdoutLineBuf = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
this.appendCliLogs(run, 'stdout', text);
run.stdoutBuffer += text;
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
@ -1380,6 +1524,9 @@ export class TeamProvisioningService {
} catch {
// Not valid JSON — check for auth failure in raw text output
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed)) {
this.failProvisioningWithApiError(run, trimmed);
}
}
}
@ -1398,6 +1545,7 @@ export class TeamProvisioningService {
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
this.appendCliLogs(run, 'stderr', text);
run.stderrBuffer += text;
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
@ -1405,6 +1553,9 @@ export class TeamProvisioningService {
// Detect auth failure early instead of waiting for 5-minute timeout
this.handleAuthFailureInOutput(run, text, 'stderr');
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
this.failProvisioningWithApiError(run, text);
}
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
@ -1460,6 +1611,10 @@ export class TeamProvisioningService {
startedAt,
stdoutBuffer: '',
stderrBuffer: '',
claudeLogLines: [],
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
claudeLogsUpdatedAt: undefined,
processKilled: false,
finalizingByTimeout: false,
cancelRequested: false,
@ -1744,6 +1899,10 @@ export class TeamProvisioningService {
startedAt,
stdoutBuffer: '',
stderrBuffer: '',
claudeLogLines: [],
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
claudeLogsUpdatedAt: undefined,
processKilled: false,
finalizingByTimeout: false,
cancelRequested: false,
@ -1800,7 +1959,12 @@ export class TeamProvisioningService {
);
}
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
const prompt = buildLaunchPrompt(
request,
expectedMemberSpecs,
existingTasks,
Boolean(previousSessionId)
);
let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv();
const launchArgs = [
@ -2480,6 +2644,10 @@ export class TeamProvisioningService {
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
this.handleAuthFailureInOutput(run, text, 'assistant');
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
this.failProvisioningWithApiError(run, text);
return;
}
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
// During provisioning (before provisioningComplete), accumulate for live UI preview.
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
@ -2735,9 +2903,39 @@ export class TeamProvisioningService {
// Handle compact_boundary — context was compacted, next assistant message will carry fresh usage
if (msg.type === 'system') {
const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
if (sub === 'compact_boundary' && run.leadContextUsage) {
run.leadContextUsage.lastUsageMessageId = null;
logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`);
if (sub === 'compact_boundary') {
if (run.leadContextUsage) {
run.leadContextUsage.lastUsageMessageId = null;
}
// Extract compact metadata for the system message
const meta = (msg as Record<string, unknown>).compact_metadata as
| Record<string, unknown>
| 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 compactMsg: InboxMessage = {
from: 'system',
text: `Context compacted${tokenInfo}, trigger: ${trigger}`,
timestamp: nowIso(),
read: true,
summary: `Context compacted (${trigger})`,
messageId: `compact-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, compactMsg);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'compact_boundary',
});
logger.info(
`[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}`
);
}
}
}
@ -2750,19 +2948,24 @@ export class TeamProvisioningService {
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
// Guard: must be set synchronously BEFORE any await to prevent
// double-invocation from filesystem monitor + stream-json racing.
if (run.provisioningComplete || run.cancelRequested) return;
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
// but the filesystem monitor observed files on disk.
const authFailureText = [
const preCompleteText = [
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
]
.filter(Boolean)
.join('\n')
.trim();
if (authFailureText && this.isAuthFailureWarning(authFailureText)) {
this.handleAuthFailureInOutput(run, authFailureText, 'pre-complete');
if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
this.failProvisioningWithApiError(run, preCompleteText);
return;
}
if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) {
this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete');
return;
}

View file

@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list';
/** Get detailed team data */
export const TEAM_GET_DATA = 'team:getData';
/** Get buffered Claude CLI logs (paged, newest-first) */
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
/** Update team kanban state */
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';

View file

@ -70,6 +70,7 @@ import {
TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
@ -197,6 +198,8 @@ import type {
TaskChangeSetV2,
TaskComment,
TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamConfig,
TeamCreateConfigRequest,
TeamCreateRequest,
@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = {
getData: async (teamName: string) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
},
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
},
deleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
},

View file

@ -45,6 +45,8 @@ import type {
SshLastConnection,
SubagentDetail,
TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI {
getData: async (_teamName: string): Promise<TeamData> => {
throw new Error('Teams detail is not available in browser mode');
},
getClaudeLogs: async (
_teamName: string,
_query?: TeamClaudeLogsQuery
): Promise<TeamClaudeLogsResponse> => {
console.warn('[HttpAPIClient] getClaudeLogs is not available in browser mode');
return { lines: [], total: 0, hasMore: false };
},
deleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team deletion is not available in browser mode');
},

View file

@ -0,0 +1,134 @@
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 { Terminal } from 'lucide-react';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import type { TeamClaudeLogsResponse } from '@shared/types';
const PAGE_SIZE = 100;
const POLL_MS = 2000;
const ONLINE_WINDOW_MS = 10_000;
interface ClaudeLogsSectionProps {
teamName: string;
}
function isRecent(updatedAt: string | undefined): boolean {
if (!updatedAt) return false;
const t = Date.parse(updatedAt);
if (Number.isNaN(t)) return false;
return Date.now() - t <= ONLINE_WINDOW_MS;
}
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlightRef = useRef(false);
useEffect(() => {
setVisibleCount(PAGE_SIZE);
setData({ lines: [], total: 0, hasMore: false });
setError(null);
}, [teamName]);
useEffect(() => {
let cancelled = false;
const fetchLogs = async (): Promise<void> => {
if (inFlightRef.current) return;
inFlightRef.current = true;
try {
setLoading(true);
const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount });
if (cancelled) return;
setData(next);
setError(null);
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
inFlightRef.current = false;
if (!cancelled) setLoading(false);
}
};
void fetchLogs();
const id = window.setInterval(() => void fetchLogs(), POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [teamName, visibleCount]);
const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]);
const badge = data.total > 0 ? data.total : undefined;
const showMoreVisible = data.hasMore;
const headerExtra = online ? (
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null;
return (
<CollapsibleTeamSection
sectionId="claude-logs"
title="Claude logs"
icon={<Terminal size={14} />}
badge={badge}
headerExtra={headerExtra}
defaultOpen
contentClassName="pt-0"
>
<div className="flex items-center justify-between gap-2 pb-2">
<span className="text-[11px] text-[var(--color-text-muted)]">
{data.total > 0 ? (
<>
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
<span className="font-mono">{data.total}</span>
</>
) : (
'No logs yet.'
)}
</span>
{showMoreVisible && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
>
Show more
</Button>
)}
</div>
<div
className={cn(
'max-h-[320px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2',
loading && 'opacity-80'
)}
>
{error ? (
<p className="text-xs text-red-300">{error}</p>
) : data.lines.length > 0 ? (
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-4 text-[var(--color-text-secondary)]">
{data.lines.join('\n')}
</pre>
) : (
<p className="text-xs text-[var(--color-text-muted)]">
{loading ? 'Loading…' : 'No logs captured.'}
</p>
)}
</div>
</CollapsibleTeamSection>
);
};

View file

@ -44,27 +44,66 @@ function formatElapsed(seconds: number): string {
return `${m}:${String(s).padStart(2, '0')}`;
}
function useElapsedTimer(startedAt?: string): string | null {
const [elapsed, setElapsed] = useState<string | null>(null);
function useElapsedTimer(startedAt?: string, isRunning = true): string | null {
const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(null);
useEffect(() => {
if (!startedAt) return () => setElapsed(null);
if (!startedAt) {
setElapsedSeconds(null);
return;
}
const startMs = Date.parse(startedAt);
if (isNaN(startMs)) return () => setElapsed(null);
if (isNaN(startMs)) {
setElapsedSeconds(null);
return;
}
const computeElapsedSeconds = (): number =>
Math.max(0, Math.floor((Date.now() - startMs) / 1000));
if (!isRunning) {
// Freeze timer on terminal states (failed/ready/cancelled) instead of continuing to tick.
setElapsedSeconds((prev) => (prev === null ? computeElapsedSeconds() : prev));
return;
}
const tick = (): void => {
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
setElapsed(formatElapsed(seconds));
setElapsedSeconds(computeElapsedSeconds());
};
tick();
const id = window.setInterval(tick, 1000);
return () => {
window.clearInterval(id);
};
}, [startedAt]);
}, [startedAt, isRunning]);
if (!startedAt) return null;
return elapsed;
if (elapsedSeconds === null) return null;
return formatElapsed(elapsedSeconds);
}
function sanitizeAssistantOutput(raw?: string, isError = false): string | null {
if (!raw) return null;
if (!isError) return raw;
const looksLikeRawApiEnvelope =
raw.includes('API Error: 400') &&
(raw.includes('"_requests"') ||
raw.includes('"session_id"') ||
raw.includes('"parent_tool_use_id"') ||
raw.includes('\\u000'));
if (!looksLikeRawApiEnvelope) {
return raw;
}
return (
'API Error: 400\n\n' +
'Raw payload from CLI stream hidden because it contains encoded/binary-like content.\n\n' +
'Open **CLI logs** below for readable diagnostics.'
);
}
export const ProvisioningProgressBlock = ({
@ -81,11 +120,12 @@ export const ProvisioningProgressBlock = ({
assistantOutput,
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt);
const [logsOpen, setLogsOpen] = useState(false);
const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail));
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error';
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
// Auto-scroll assistant output
useEffect(() => {
@ -99,6 +139,14 @@ export const ProvisioningProgressBlock = ({
setLiveOutputOpen(defaultLiveOutputOpen);
}, [defaultLiveOutputOpen]);
// On error with logs available, prioritize logs view over noisy live stream payload.
useEffect(() => {
if (isError && cliLogsTail) {
setLogsOpen(true);
setLiveOutputOpen(false);
}
}, [isError, cliLogsTail]);
return (
<div
className={cn(
@ -190,8 +238,8 @@ export const ProvisioningProgressBlock = ({
isError && 'border-red-500/40'
)}
>
{assistantOutput ? (
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" />
{displayAssistantOutput ? (
<MarkdownViewer content={displayAssistantOutput} bare maxHeight="max-h-none" />
) : (
<p
className={cn(

View file

@ -70,6 +70,7 @@ import { MessageComposer } from './messages/MessageComposer';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { ClaudeLogsSection } from './ClaudeLogsSection';
import { ProcessesSection } from './ProcessesSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection';
@ -1171,6 +1172,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</CollapsibleTeamSection>
)}
<ClaudeLogsSection teamName={teamName} />
<CollapsibleTeamSection
sectionId="messages"
title="Messages"

View file

@ -255,6 +255,21 @@ export const TeamListView = (): React.JSX.Element => {
};
}, [electronMode, teams]);
// Refresh alive teams when opening the create dialog so conflict warning is accurate.
useEffect(() => {
if (!electronMode || !showCreateDialog) return;
let cancelled = false;
void api.teams
.aliveList()
.then((list) => {
if (!cancelled) setAliveTeams(list);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [electronMode, showCreateDialog]);
const currentProjectPath = useMemo(() => {
if (viewMode === 'grouped') {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);

View file

@ -491,10 +491,11 @@ export const CreateTeamDialog = ({
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
const conflictingTeam = useMemo(() => {
if (!launchTeam) return null;
if (!activeTeams?.length || !effectiveCwd) return null;
const norm = normalizePath(effectiveCwd);
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
}, [activeTeams, effectiveCwd]);
}, [activeTeams, effectiveCwd, launchTeam]);
// Reset dismiss when conflict target changes
useEffect(() => {
@ -554,6 +555,18 @@ export const CreateTeamDialog = ({
})();
};
const handleTeamNameChange = (value: string): void => {
setTeamName(value);
setFieldErrors((prev) => {
if (!prev.teamName) return prev;
const { teamName: _teamName, ...rest } = prev;
if (!rest.members && !rest.cwd && localError === 'Check form fields') {
setLocalError(null);
}
return rest;
});
};
return (
<Dialog
open={open}
@ -580,13 +593,16 @@ export const CreateTeamDialog = ({
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this
project
Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
this working directory
</p>
<p className="text-amber-300/80">
Running two teams in the same directory is risky they may conflict editing the
same files. Consider using a different directory or a git worktree for isolation.
</p>
<p className="text-[11px] text-amber-300/70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div>
<button
type="button"
@ -641,7 +657,7 @@ export const CreateTeamDialog = ({
id="team-name"
className="h-8 text-xs"
value={teamName}
onChange={(event) => setTeamName(event.target.value)}
onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder="team-alpha"
/>
{existingTeamNames.includes(sanitizedTeamName) ? (

View file

@ -319,13 +319,16 @@ export const LaunchTeamDialog = ({
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this
project
Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
this working directory
</p>
<p className="text-amber-300/80">
Running two teams in the same directory is risky they may conflict editing the
same files. Consider using a different directory or a git worktree for isolation.
</p>
<p className="text-[11px] text-amber-300/70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div>
<button
type="button"

View file

@ -1,7 +1,7 @@
import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
@ -40,18 +40,13 @@ export const MemberCard = ({
onSendMessage,
onAssignTask,
}: MemberCardProps): React.JSX.Element => {
const teamName = useStore((s) => s.selectedTeamName);
const leadContext = useStore((s) =>
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
);
// TODO: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
// const leadContext = useStore((s) =>
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const presenceLabel = getPresenceLabel(
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
leadContext?.percent
);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0;
@ -182,29 +177,7 @@ export const MemberCard = ({
/>
</div>
)}
{leadContext && leadContext.percent > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
<div
className={`h-full rounded-full transition-all duration-500 ${
leadContext.percent > 90
? 'bg-red-500'
: leadContext.percent > 70
? 'bg-amber-500'
: 'bg-blue-500'
}`}
style={{ width: `${Math.min(leadContext.percent, 100)}%` }}
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
Context: {Math.round(leadContext.percent)}% (
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
{(leadContext.contextWindow / 1000).toFixed(0)}k tokens)
</TooltipContent>
</Tooltip>
)}
{/* TODO: lead context bar disabled — usage formula is inaccurate */}
</div>
{!isRemoved && (
<div className="flex shrink-0 items-center gap-0.5">

View file

@ -3,7 +3,7 @@ import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { Pencil } from 'lucide-react';
@ -31,20 +31,15 @@ export const MemberDetailHeader = ({
}: MemberDetailHeaderProps): React.JSX.Element => {
const [editing, setEditing] = useState(false);
const teamName = useStore((s) => s.selectedTeamName);
const leadContext = useStore((s) =>
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
);
// TODO: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
// const leadContext = useStore((s) =>
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const colors = getTeamColorSet(member.color ?? '');
const role = member.role || formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel(
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
leadContext?.percent
);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const canEditRole =
@ -107,12 +102,7 @@ export const MemberDetailHeader = ({
>
{presenceLabel}
</Badge>
{leadContext && leadContext.percent > 0 && (
<span className="text-[10px] text-[var(--color-text-muted)]">
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
{(leadContext.contextWindow / 1000).toFixed(0)}k
</span>
)}
{/* TODO: lead context token display disabled — usage formula is inaccurate */}
</>
)}
</div>

View file

@ -148,10 +148,11 @@ export const MessageComposer = ({
const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
const leadContext = useStore((s) =>
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
);
// TODO: lead context ring disabled — usage formula is inaccurate
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
// const leadContext = useStore((s) =>
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// );
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
const canAttach = supportsAttachments && canAddMore;
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
@ -420,7 +421,7 @@ export const MessageComposer = ({
disabled={sending}
cornerAction={
<div className="flex items-center gap-2">
{leadContext && leadContext.percent > 0 && <ContextRing ctx={leadContext} />}
{/* TODO: ContextRing disabled — usage formula is inaccurate */}
<Tooltip>
<TooltipTrigger asChild>
<button

View file

@ -549,7 +549,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const state = get();
// Use display name from teams list or selected team data if available
const teamSummary = state.teamByName[teamName];
const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
const selectedTeamDisplayName =
state.selectedTeamName === teamName ? state.selectedTeamData?.config.name : undefined;
const displayName = teamSummary?.displayName || selectedTeamDisplayName || teamName;
const allTabs = state.getAllPaneTabs();
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);

View file

@ -398,6 +398,10 @@ export interface HttpServerAPI {
export interface TeamsAPI {
list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>;
getClaudeLogs: (
teamName: string,
query?: import('./team').TeamClaudeLogsQuery
) => Promise<import('./team').TeamClaudeLogsResponse>;
deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;
permanentlyDeleteTeam: (teamName: string) => Promise<void>;

View file

@ -311,6 +311,24 @@ export interface TeamChangeEvent {
detail?: string;
}
export interface TeamClaudeLogsQuery {
/** Offset in lines from the newest log line (0 = newest). */
offset?: number;
/** Max number of lines to return. */
limit?: number;
}
export interface TeamClaudeLogsResponse {
/** Log lines ordered newest-first. */
lines: string[];
/** Total number of buffered lines available in memory. */
total: number;
/** True when there are older lines beyond the current window. */
hasMore: boolean;
/** ISO timestamp of the last observed CLI output for this team. */
updatedAt?: string;
}
export type TeamProvisioningState =
| 'idle'
| 'validating'