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:
parent
82bea01e0f
commit
fdb52922fe
17 changed files with 553 additions and 87 deletions
|
|
@ -14,6 +14,7 @@ import {
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
|
TEAM_GET_CLAUDE_LOGS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
|
|
@ -108,6 +109,8 @@ import type {
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
TeamData,
|
||||||
|
TeamClaudeLogsQuery,
|
||||||
|
TeamClaudeLogsResponse,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
|
|
@ -195,6 +198,7 @@ export function initializeTeamHandlers(
|
||||||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_LIST, handleListTeams);
|
ipcMain.handle(TEAM_LIST, handleListTeams);
|
||||||
ipcMain.handle(TEAM_GET_DATA, handleGetData);
|
ipcMain.handle(TEAM_GET_DATA, handleGetData);
|
||||||
|
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||||
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
||||||
|
|
@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_LIST);
|
ipcMain.removeHandler(TEAM_LIST);
|
||||||
ipcMain.removeHandler(TEAM_GET_DATA);
|
ipcMain.removeHandler(TEAM_GET_DATA);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||||
ipcMain.removeHandler(TEAM_CREATE);
|
ipcMain.removeHandler(TEAM_CREATE);
|
||||||
ipcMain.removeHandler(TEAM_LAUNCH);
|
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(
|
async function handleCreateTeam(
|
||||||
event: IpcMainInvokeEvent,
|
event: IpcMainInvokeEvent,
|
||||||
request: unknown
|
request: unknown
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,14 @@ interface ProvisioningRun {
|
||||||
progress: TeamProvisioningProgress;
|
progress: TeamProvisioningProgress;
|
||||||
stdoutBuffer: string;
|
stdoutBuffer: string;
|
||||||
stderrBuffer: 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;
|
processKilled: boolean;
|
||||||
finalizingByTimeout: boolean;
|
finalizingByTimeout: boolean;
|
||||||
cancelRequested: boolean;
|
cancelRequested: boolean;
|
||||||
|
|
@ -719,7 +727,8 @@ ${membersFooter}
|
||||||
function buildLaunchPrompt(
|
function buildLaunchPrompt(
|
||||||
request: TeamLaunchRequest,
|
request: TeamLaunchRequest,
|
||||||
members: TeamCreateRequest['members'],
|
members: TeamCreateRequest['members'],
|
||||||
tasks: TeamTask[]
|
tasks: TeamTask[],
|
||||||
|
isResume: boolean
|
||||||
): string {
|
): string {
|
||||||
const membersBlock = buildMembersPrompt(members);
|
const membersBlock = buildMembersPrompt(members);
|
||||||
const userPromptBlock = request.prompt?.trim()
|
const userPromptBlock = request.prompt?.trim()
|
||||||
|
|
@ -828,7 +837,9 @@ ${memberSpawnInstructions}
|
||||||
? `Members:\n${membersBlock}`
|
? `Members:\n${membersBlock}`
|
||||||
: 'Members: (none — solo team lead)';
|
: '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 running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||||
You are "${leadName}", the team lead.
|
You are "${leadName}", the team lead.
|
||||||
|
|
@ -967,6 +978,8 @@ interface CachedProbeResult {
|
||||||
let cachedProbeResult: CachedProbeResult | null = null;
|
let cachedProbeResult: CachedProbeResult | null = null;
|
||||||
|
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
|
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||||
|
|
||||||
private readonly runs = new Map<string, ProvisioningRun>();
|
private readonly runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly activeByTeam = new Map<string, string>();
|
private readonly activeByTeam = new Map<string, string>();
|
||||||
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
||||||
|
|
@ -983,6 +996,71 @@ export class TeamProvisioningService {
|
||||||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
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.
|
* Serializes operations per team name using promise-chaining.
|
||||||
* Same pattern as withInboxLock / withTaskLock.
|
* 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.
|
* Detects auth failure keywords in stderr/stdout during provisioning.
|
||||||
* On first detection: kills process, waits, and respawns automatically.
|
* On first detection: kills process, waits, and respawns automatically.
|
||||||
|
|
@ -1250,6 +1389,10 @@ export class TeamProvisioningService {
|
||||||
// Reset buffers for fresh attempt
|
// Reset buffers for fresh attempt
|
||||||
run.stdoutBuffer = '';
|
run.stdoutBuffer = '';
|
||||||
run.stderrBuffer = '';
|
run.stderrBuffer = '';
|
||||||
|
run.claudeLogLines = [];
|
||||||
|
run.stdoutLogLineBuf = '';
|
||||||
|
run.stderrLogLineBuf = '';
|
||||||
|
run.claudeLogsUpdatedAt = undefined;
|
||||||
run.authFailureRetried = true;
|
run.authFailureRetried = true;
|
||||||
|
|
||||||
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
|
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
|
||||||
|
|
@ -1362,6 +1505,7 @@ export class TeamProvisioningService {
|
||||||
let stdoutLineBuf = '';
|
let stdoutLineBuf = '';
|
||||||
child.stdout.on('data', (chunk: Buffer) => {
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
const text = chunk.toString('utf8');
|
const text = chunk.toString('utf8');
|
||||||
|
this.appendCliLogs(run, 'stdout', text);
|
||||||
run.stdoutBuffer += text;
|
run.stdoutBuffer += text;
|
||||||
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
|
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
|
||||||
run.stdoutBuffer = run.stdoutBuffer.slice(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 {
|
} catch {
|
||||||
// Not valid JSON — check for auth failure in raw text output
|
// Not valid JSON — check for auth failure in raw text output
|
||||||
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
|
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) => {
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
const text = chunk.toString('utf8');
|
const text = chunk.toString('utf8');
|
||||||
|
this.appendCliLogs(run, 'stderr', text);
|
||||||
run.stderrBuffer += text;
|
run.stderrBuffer += text;
|
||||||
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
|
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
|
||||||
run.stderrBuffer = run.stderrBuffer.slice(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
|
// Detect auth failure early instead of waiting for 5-minute timeout
|
||||||
this.handleAuthFailureInOutput(run, text, 'stderr');
|
this.handleAuthFailureInOutput(run, text, 'stderr');
|
||||||
|
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
|
||||||
|
this.failProvisioningWithApiError(run, text);
|
||||||
|
}
|
||||||
|
|
||||||
const currentTs = Date.now();
|
const currentTs = Date.now();
|
||||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||||
|
|
@ -1460,6 +1611,10 @@ export class TeamProvisioningService {
|
||||||
startedAt,
|
startedAt,
|
||||||
stdoutBuffer: '',
|
stdoutBuffer: '',
|
||||||
stderrBuffer: '',
|
stderrBuffer: '',
|
||||||
|
claudeLogLines: [],
|
||||||
|
stdoutLogLineBuf: '',
|
||||||
|
stderrLogLineBuf: '',
|
||||||
|
claudeLogsUpdatedAt: undefined,
|
||||||
processKilled: false,
|
processKilled: false,
|
||||||
finalizingByTimeout: false,
|
finalizingByTimeout: false,
|
||||||
cancelRequested: false,
|
cancelRequested: false,
|
||||||
|
|
@ -1744,6 +1899,10 @@ export class TeamProvisioningService {
|
||||||
startedAt,
|
startedAt,
|
||||||
stdoutBuffer: '',
|
stdoutBuffer: '',
|
||||||
stderrBuffer: '',
|
stderrBuffer: '',
|
||||||
|
claudeLogLines: [],
|
||||||
|
stdoutLogLineBuf: '',
|
||||||
|
stderrLogLineBuf: '',
|
||||||
|
claudeLogsUpdatedAt: undefined,
|
||||||
processKilled: false,
|
processKilled: false,
|
||||||
finalizingByTimeout: false,
|
finalizingByTimeout: false,
|
||||||
cancelRequested: 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>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||||
const launchArgs = [
|
const launchArgs = [
|
||||||
|
|
@ -2480,6 +2644,10 @@ export class TeamProvisioningService {
|
||||||
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
|
// 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".
|
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
|
||||||
this.handleAuthFailureInOutput(run, text, 'assistant');
|
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)}`);
|
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
|
||||||
// During provisioning (before provisioningComplete), accumulate for live UI preview.
|
// During provisioning (before provisioningComplete), accumulate for live UI preview.
|
||||||
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
|
// 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
|
// Handle compact_boundary — context was compacted, next assistant message will carry fresh usage
|
||||||
if (msg.type === 'system') {
|
if (msg.type === 'system') {
|
||||||
const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
|
const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
|
||||||
if (sub === 'compact_boundary' && run.leadContextUsage) {
|
if (sub === 'compact_boundary') {
|
||||||
run.leadContextUsage.lastUsageMessageId = null;
|
if (run.leadContextUsage) {
|
||||||
logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`);
|
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> {
|
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||||
// Guard: must be set synchronously BEFORE any await to prevent
|
// Guard: must be set synchronously BEFORE any await to prevent
|
||||||
// double-invocation from filesystem monitor + stream-json racing.
|
// 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
|
// Prevent false "ready" when auth failure was printed as assistant text or logs
|
||||||
// but the filesystem monitor observed files on disk.
|
// but the filesystem monitor observed files on disk.
|
||||||
const authFailureText = [
|
const preCompleteText = [
|
||||||
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
|
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
|
||||||
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
|
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
if (authFailureText && this.isAuthFailureWarning(authFailureText)) {
|
if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
|
||||||
this.handleAuthFailureInOutput(run, authFailureText, 'pre-complete');
|
this.failProvisioningWithApiError(run, preCompleteText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) {
|
||||||
|
this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list';
|
||||||
/** Get detailed team data */
|
/** Get detailed team data */
|
||||||
export const TEAM_GET_DATA = 'team:getData';
|
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 */
|
/** Update team kanban state */
|
||||||
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
|
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ import {
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
|
TEAM_GET_CLAUDE_LOGS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
|
|
@ -197,6 +198,8 @@ import type {
|
||||||
TaskChangeSetV2,
|
TaskChangeSetV2,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
|
TeamClaudeLogsQuery,
|
||||||
|
TeamClaudeLogsResponse,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
|
|
@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = {
|
||||||
getData: async (teamName: string) => {
|
getData: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
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) => {
|
deleteTeam: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
|
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ import type {
|
||||||
SshLastConnection,
|
SshLastConnection,
|
||||||
SubagentDetail,
|
SubagentDetail,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
|
TeamClaudeLogsQuery,
|
||||||
|
TeamClaudeLogsResponse,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
TeamData,
|
||||||
|
|
@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getData: async (_teamName: string): Promise<TeamData> => {
|
getData: async (_teamName: string): Promise<TeamData> => {
|
||||||
throw new Error('Teams detail is not available in browser mode');
|
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> => {
|
deleteTeam: async (_teamName: string): Promise<void> => {
|
||||||
throw new Error('Team deletion is not available in browser mode');
|
throw new Error('Team deletion is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
134
src/renderer/components/team/ClaudeLogsSection.tsx
Normal file
134
src/renderer/components/team/ClaudeLogsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -44,27 +44,66 @@ function formatElapsed(seconds: number): string {
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useElapsedTimer(startedAt?: string): string | null {
|
function useElapsedTimer(startedAt?: string, isRunning = true): string | null {
|
||||||
const [elapsed, setElapsed] = useState<string | null>(null);
|
const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!startedAt) return () => setElapsed(null);
|
if (!startedAt) {
|
||||||
|
setElapsedSeconds(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const startMs = Date.parse(startedAt);
|
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 tick = (): void => {
|
||||||
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
setElapsedSeconds(computeElapsedSeconds());
|
||||||
setElapsed(formatElapsed(seconds));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 1000);
|
const id = window.setInterval(tick, 1000);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(id);
|
window.clearInterval(id);
|
||||||
};
|
};
|
||||||
}, [startedAt]);
|
}, [startedAt, isRunning]);
|
||||||
|
|
||||||
if (!startedAt) return null;
|
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 = ({
|
export const ProvisioningProgressBlock = ({
|
||||||
|
|
@ -81,11 +120,12 @@ export const ProvisioningProgressBlock = ({
|
||||||
assistantOutput,
|
assistantOutput,
|
||||||
className,
|
className,
|
||||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||||
const elapsed = useElapsedTimer(startedAt);
|
const elapsed = useElapsedTimer(startedAt, loading);
|
||||||
const [logsOpen, setLogsOpen] = useState(false);
|
const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail));
|
||||||
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
||||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const isError = tone === 'error';
|
const isError = tone === 'error';
|
||||||
|
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||||
|
|
||||||
// Auto-scroll assistant output
|
// Auto-scroll assistant output
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -99,6 +139,14 @@ export const ProvisioningProgressBlock = ({
|
||||||
setLiveOutputOpen(defaultLiveOutputOpen);
|
setLiveOutputOpen(defaultLiveOutputOpen);
|
||||||
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -190,8 +238,8 @@ export const ProvisioningProgressBlock = ({
|
||||||
isError && 'border-red-500/40'
|
isError && 'border-red-500/40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{assistantOutput ? (
|
{displayAssistantOutput ? (
|
||||||
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" />
|
<MarkdownViewer content={displayAssistantOutput} bare maxHeight="max-h-none" />
|
||||||
) : (
|
) : (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ import { MessageComposer } from './messages/MessageComposer';
|
||||||
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
||||||
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
|
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||||||
import { ProcessesSection } from './ProcessesSection';
|
import { ProcessesSection } from './ProcessesSection';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
|
@ -1171,6 +1172,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ClaudeLogsSection teamName={teamName} />
|
||||||
|
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
sectionId="messages"
|
sectionId="messages"
|
||||||
title="Messages"
|
title="Messages"
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,21 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
};
|
};
|
||||||
}, [electronMode, teams]);
|
}, [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(() => {
|
const currentProjectPath = useMemo(() => {
|
||||||
if (viewMode === 'grouped') {
|
if (viewMode === 'grouped') {
|
||||||
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
||||||
|
|
|
||||||
|
|
@ -491,10 +491,11 @@ export const CreateTeamDialog = ({
|
||||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||||
|
|
||||||
const conflictingTeam = useMemo(() => {
|
const conflictingTeam = useMemo(() => {
|
||||||
|
if (!launchTeam) return null;
|
||||||
if (!activeTeams?.length || !effectiveCwd) return null;
|
if (!activeTeams?.length || !effectiveCwd) return null;
|
||||||
const norm = normalizePath(effectiveCwd);
|
const norm = normalizePath(effectiveCwd);
|
||||||
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
|
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
|
||||||
}, [activeTeams, effectiveCwd]);
|
}, [activeTeams, effectiveCwd, launchTeam]);
|
||||||
|
|
||||||
// Reset dismiss when conflict target changes
|
// Reset dismiss when conflict target changes
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -580,13 +593,16 @@ export const CreateTeamDialog = ({
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<p className="font-medium text-amber-300">
|
<p className="font-medium text-amber-300">
|
||||||
Team “{conflictingTeam.displayName}” is already running in this
|
Another team “{conflictingTeam.displayName}” is already running for
|
||||||
project
|
this working directory
|
||||||
</p>
|
</p>
|
||||||
<p className="text-amber-300/80">
|
<p className="text-amber-300/80">
|
||||||
Running two teams in the same directory is risky — they may conflict editing the
|
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.
|
same files. Consider using a different directory or a git worktree for isolation.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-[11px] text-amber-300/70">
|
||||||
|
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -641,7 +657,7 @@ export const CreateTeamDialog = ({
|
||||||
id="team-name"
|
id="team-name"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
value={teamName}
|
value={teamName}
|
||||||
onChange={(event) => setTeamName(event.target.value)}
|
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||||
placeholder="team-alpha"
|
placeholder="team-alpha"
|
||||||
/>
|
/>
|
||||||
{existingTeamNames.includes(sanitizedTeamName) ? (
|
{existingTeamNames.includes(sanitizedTeamName) ? (
|
||||||
|
|
|
||||||
|
|
@ -319,13 +319,16 @@ export const LaunchTeamDialog = ({
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<p className="font-medium text-amber-300">
|
<p className="font-medium text-amber-300">
|
||||||
Team “{conflictingTeam.displayName}” is already running in this
|
Another team “{conflictingTeam.displayName}” is already running for
|
||||||
project
|
this working directory
|
||||||
</p>
|
</p>
|
||||||
<p className="text-amber-300/80">
|
<p className="text-amber-300/80">
|
||||||
Running two teams in the same directory is risky — they may conflict editing the
|
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.
|
same files. Consider using a different directory or a git worktree for isolation.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-[11px] text-amber-300/70">
|
||||||
|
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||||
|
|
@ -40,18 +40,13 @@ export const MemberCard = ({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element => {
|
||||||
const teamName = useStore((s) => s.selectedTeamName);
|
// TODO: lead context display disabled — usage formula is inaccurate
|
||||||
const leadContext = useStore((s) =>
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// const leadContext = useStore((s) =>
|
||||||
);
|
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||||
|
// );
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
const presenceLabel = getPresenceLabel(
|
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
member,
|
|
||||||
isTeamAlive,
|
|
||||||
isTeamProvisioning,
|
|
||||||
leadActivity,
|
|
||||||
leadContext?.percent
|
|
||||||
);
|
|
||||||
const colors = getTeamColorSet(memberColor);
|
const colors = getTeamColorSet(memberColor);
|
||||||
const pending = taskCounts?.pending ?? 0;
|
const pending = taskCounts?.pending ?? 0;
|
||||||
const inProgress = taskCounts?.inProgress ?? 0;
|
const inProgress = taskCounts?.inProgress ?? 0;
|
||||||
|
|
@ -182,29 +177,7 @@ export const MemberCard = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{leadContext && leadContext.percent > 0 && (
|
{/* TODO: lead context bar disabled — usage formula is inaccurate */}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{!isRemoved && (
|
{!isRemoved && (
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
@ -31,20 +31,15 @@ export const MemberDetailHeader = ({
|
||||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
const teamName = useStore((s) => s.selectedTeamName);
|
// TODO: lead context display disabled — usage formula is inaccurate
|
||||||
const leadContext = useStore((s) =>
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// const leadContext = useStore((s) =>
|
||||||
);
|
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||||
|
// );
|
||||||
|
|
||||||
const colors = getTeamColorSet(member.color ?? '');
|
const colors = getTeamColorSet(member.color ?? '');
|
||||||
const role = member.role || formatAgentRole(member.agentType);
|
const role = member.role || formatAgentRole(member.agentType);
|
||||||
const presenceLabel = getPresenceLabel(
|
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
member,
|
|
||||||
isTeamAlive,
|
|
||||||
isTeamProvisioning,
|
|
||||||
leadActivity,
|
|
||||||
leadContext?.percent
|
|
||||||
);
|
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
|
|
||||||
const canEditRole =
|
const canEditRole =
|
||||||
|
|
@ -107,12 +102,7 @@ export const MemberDetailHeader = ({
|
||||||
>
|
>
|
||||||
{presenceLabel}
|
{presenceLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
{leadContext && leadContext.percent > 0 && (
|
{/* TODO: lead context token display disabled — usage formula is inaccurate */}
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
|
||||||
{(leadContext.contextWindow / 1000).toFixed(0)}k
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,11 @@ export const MessageComposer = ({
|
||||||
const selectedMember = members.find((m) => m.name === recipient);
|
const selectedMember = members.find((m) => m.name === recipient);
|
||||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||||
const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
// TODO: lead context ring disabled — usage formula is inaccurate
|
||||||
const leadContext = useStore((s) =>
|
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
||||||
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
// const leadContext = useStore((s) =>
|
||||||
);
|
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||||
|
// );
|
||||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||||
const canAttach = supportsAttachments && canAddMore;
|
const canAttach = supportsAttachments && canAddMore;
|
||||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||||
|
|
@ -420,7 +421,7 @@ export const MessageComposer = ({
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{leadContext && leadContext.percent > 0 && <ContextRing ctx={leadContext} />}
|
{/* TODO: ContextRing disabled — usage formula is inaccurate */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -549,7 +549,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const state = get();
|
const state = get();
|
||||||
// Use display name from teams list or selected team data if available
|
// Use display name from teams list or selected team data if available
|
||||||
const teamSummary = state.teamByName[teamName];
|
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 allTabs = state.getAllPaneTabs();
|
||||||
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,10 @@ export interface HttpServerAPI {
|
||||||
export interface TeamsAPI {
|
export interface TeamsAPI {
|
||||||
list: () => Promise<TeamSummary[]>;
|
list: () => Promise<TeamSummary[]>;
|
||||||
getData: (teamName: string) => Promise<TeamData>;
|
getData: (teamName: string) => Promise<TeamData>;
|
||||||
|
getClaudeLogs: (
|
||||||
|
teamName: string,
|
||||||
|
query?: import('./team').TeamClaudeLogsQuery
|
||||||
|
) => Promise<import('./team').TeamClaudeLogsResponse>;
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
restoreTeam: (teamName: string) => Promise<void>;
|
restoreTeam: (teamName: string) => Promise<void>;
|
||||||
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,24 @@ export interface TeamChangeEvent {
|
||||||
detail?: string;
|
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 =
|
export type TeamProvisioningState =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'validating'
|
| 'validating'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue