fix: standardize file naming conventions and improve error handling in team provisioning

- Updated file naming in release workflow to use consistent dot notation for better clarity.
- Enhanced RELEASE.md to reflect updated file names for download links.
- Added logic to filter out the "user" pseudo-member in TeamMemberResolver to prevent confusion in team configurations.
- Improved error handling in TeamProvisioningService to avoid caching authentication failures and ensure accurate readiness status.
- Introduced visual tone handling in ProvisioningProgressBlock for better user feedback on errors.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-03 14:57:06 +02:00
parent 71e88fd267
commit 2615ed0ad7
7 changed files with 181 additions and 72 deletions

View file

@ -277,10 +277,10 @@ jobs:
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}" DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}"
declare -A FILES=( declare -A FILES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude-Agent-Teams-UI-${VERSION}-arm64.dmg" ["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
["Claude-Agent-Teams-UI-x64.dmg"]="Claude-Agent-Teams-UI-${VERSION}.dmg" ["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}.dmg"
["Claude-Agent-Teams-UI-Setup.exe"]="Claude-Agent-Teams-UI-Setup-${VERSION}.exe" ["Claude-Agent-Teams-UI-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe"
["Claude-Agent-Teams-UI.AppImage"]="Claude-Agent-Teams-UI-${VERSION}.AppImage" ["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage"
["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb" ["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb"
["Claude-Agent-Teams-UI-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm" ["Claude-Agent-Teams-UI-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm"
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman" ["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman"

View file

@ -67,23 +67,23 @@ EOF
<table> <table>
<tr> <tr>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-<VERSION>-arm64.dmg"> <a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-arm64.dmg">
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" /> <img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
</a> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-<VERSION>.dmg"> <a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>.dmg">
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" /> <img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-Setup-<VERSION>.exe"> <a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI.Setup.<VERSION>.exe">
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" /> <img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
</a> </a>
<br /> <br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub> <sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-<VERSION>.AppImage"> <a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>.AppImage">
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" /> <img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
</a> </a>
<br /> <br />
@ -123,12 +123,12 @@ electron-builder generates these artifacts per platform:
| Platform | Versioned Name | Stable Name (for /latest/download) | | Platform | Versioned Name | Stable Name (for /latest/download) |
|------------------|--------------------------------------------------|--------------------------------------------| |------------------|--------------------------------------------------|--------------------------------------------|
| macOS arm64 DMG | `Claude-Agent-Teams-UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | | macOS arm64 DMG | `Claude.Agent.Teams.UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
| macOS x64 DMG | `Claude-Agent-Teams-UI-<VER>.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | | macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
| macOS arm64 ZIP | `Claude-Agent-Teams-UI-<VER>-arm64-mac.zip` | — | | macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | — |
| macOS x64 ZIP | `Claude-Agent-Teams-UI-<VER>-mac.zip` | — | | macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-mac.zip` | — |
| Windows | `Claude-Agent-Teams-UI-Setup-<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` | | Windows | `Claude.Agent.Teams.UI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
| Linux AppImage | `Claude-Agent-Teams-UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` | | Linux AppImage | `Claude.Agent.Teams.UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
| Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | | Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
| Linux rpm | `claude-agent-teams-ui-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` | | Linux rpm | `claude-agent-teams-ui-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
| Linux pacman | `claude-agent-teams-ui-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` | | Linux pacman | `claude-agent-teams-ui-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |

View file

@ -74,6 +74,10 @@ export class TeamMemberResolver {
} }
} }
// "user" is a built-in pseudo-member in Claude Code's team framework
// (recipient of SendMessage to "user"). It's not a real AI teammate.
names.delete('user');
const members: ResolvedTeamMember[] = []; const members: ResolvedTeamMember[] = [];
for (const name of names) { for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name); const ownedTasks = tasks.filter((task) => task.owner === name);

View file

@ -58,6 +58,7 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 128 * 1024; const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000; const SHELL_ENV_TIMEOUT_MS = 12000;
const CLI_PREPARE_TIMEOUT_MS = 10000; const CLI_PREPARE_TIMEOUT_MS = 10000;
const PROBE_CACHE_TTL_MS = 60_000;
const PREFLIGHT_TIMEOUT_MS = 30000; const PREFLIGHT_TIMEOUT_MS = 30000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2; const PREFLIGHT_AUTH_MAX_RETRIES = 2;
@ -565,6 +566,7 @@ Constraints:
- Do NOT use TodoWrite. - Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members. - Do NOT shut down, terminate, or clean up the team or its members.
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator it is NOT a teammate.
- Keep assistant text minimal. - Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
- Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Keep the task board high-signal: avoid creating tasks for trivial micro-items.
@ -683,6 +685,7 @@ Constraints:
- Do NOT use TodoWrite. - Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members. - Do NOT shut down, terminate, or clean up the team or its members.
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator it is NOT a teammate.
- Keep assistant text minimal. - Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
- Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Keep the task board high-signal: avoid creating tasks for trivial micro-items.
@ -795,7 +798,12 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
const trimmed = buildCombinedLogs(stdoutText, stderrText).trim(); const trimmed = buildCombinedLogs(stdoutText, stderrText).trim();
if (trimmed.length > 0) { if (trimmed.length > 0) {
if (trimmed.toLowerCase().includes('please run /login')) { if (trimmed.toLowerCase().includes('please run /login')) {
return 'CLI output indicates that `-p` mode is not authenticated. `claude -p` typically requires `ANTHROPIC_API_KEY` (Agent SDK). `/login` is interactive-only and does not fix `-p`.'; return (
'Claude CLI reports it is not authenticated ("Please run /login"). ' +
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate, then retry. ' +
'For automation/headless use, prefer `claude setup-token` and export `CLAUDE_CODE_OAUTH_TOKEN`, ' +
'or set `ANTHROPIC_API_KEY` for `-p` mode.'
);
} }
return trimmed.slice(-4000); return trimmed.slice(-4000);
} }
@ -809,9 +817,9 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
interface CachedProbeResult { interface CachedProbeResult {
claudePath: string; claudePath: string;
env: NodeJS.ProcessEnv;
authSource: ProvisioningAuthSource; authSource: ProvisioningAuthSource;
warning?: string; warning?: string;
cachedAtMs: number;
} }
let cachedProbeResult: CachedProbeResult | null = null; let cachedProbeResult: CachedProbeResult | null = null;
@ -860,12 +868,21 @@ export class TeamProvisioningService {
async warmup(): Promise<void> { async warmup(): Promise<void> {
try { try {
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) {
return;
}
const claudePath = await ClaudeBinaryResolver.resolve(); const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return; if (!claudePath) return;
const { env, authSource } = await this.buildProvisioningEnv(); const { env, authSource } = await this.buildProvisioningEnv();
const cwd = process.cwd(); const cwd = process.cwd();
const probe = await this.probeClaudeRuntime(claudePath, cwd, env); const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
cachedProbeResult = { claudePath, env, authSource, warning: probe.warning }; const warning = probe.warning;
if (warning && this.isAuthFailureWarning(warning)) {
// Don't pin auth failures in cache — user may log in after startup.
cachedProbeResult = null;
} else {
cachedProbeResult = { claudePath, authSource, warning, cachedAtMs: Date.now() };
}
logger.info('CLI warmup completed'); logger.info('CLI warmup completed');
} catch (error) { } catch (error) {
logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`); logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`);
@ -880,15 +897,21 @@ export class TeamProvisioningService {
} }
if (cachedProbeResult) { if (cachedProbeResult) {
const { warning, authSource } = cachedProbeResult; const ageMs = Date.now() - cachedProbeResult.cachedAtMs;
const warnings: string[] = []; if (ageMs >= PROBE_CACHE_TTL_MS) {
if (warning) warnings.push(warning); cachedProbeResult = null;
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false; } else {
return { const { warning, authSource } = cachedProbeResult;
ready: !warning || authSource !== 'none' || !isAuthFailure, const warnings: string[] = [];
message: 'CLI is warmed up and ready to launch', if (warning) warnings.push(warning);
warnings: warnings.length > 0 ? warnings : undefined, const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
}; const ready = !warning || authSource !== 'none' || !isAuthFailure;
return {
ready,
message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
} }
const claudePath = await ClaudeBinaryResolver.resolve(); const claudePath = await ClaudeBinaryResolver.resolve();
@ -948,6 +971,19 @@ export class TeamProvisioningService {
warnings.push(probe.warning); warnings.push(probe.warning);
} }
// Cache successful/non-auth-failure results so dialogs don't rerun preflight repeatedly.
// Avoid caching auth failures — user may authenticate externally and retry without app restart.
if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
cachedProbeResult = {
claudePath,
authSource,
warning: probe.warning,
cachedAtMs: Date.now(),
};
} else {
cachedProbeResult = null;
}
return { return {
ready: true, ready: true,
message: 'CLI is warmed up and ready to launch', message: 'CLI is warmed up and ready to launch',
@ -957,12 +993,15 @@ export class TeamProvisioningService {
private isAuthFailureWarning(text: string): boolean { private isAuthFailureWarning(text: string): boolean {
const lower = text.toLowerCase(); const lower = text.toLowerCase();
const has401 = /(^|\D)401(\D|$)/.test(lower);
return ( return (
lower.includes('not authenticated') || lower.includes('not authenticated') ||
lower.includes('not logged in') || lower.includes('not logged in') ||
lower.includes('please run /login') || lower.includes('please run /login') ||
lower.includes('missing api key') || lower.includes('missing api key') ||
lower.includes('invalid api key') lower.includes('invalid api key') ||
lower.includes('unauthorized') ||
has401
); );
} }
@ -988,8 +1027,8 @@ export class TeamProvisioningService {
killProcessTree(run.child); killProcessTree(run.child);
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', { const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
error: error:
'Claude CLI is not authenticated. Run `claude` in a terminal to complete login, ' + 'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' +
'or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.', 'to authenticate, or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
}); });
run.onProgress(progress); run.onProgress(progress);
@ -2138,6 +2177,9 @@ export class TeamProvisioningService {
.map((part) => part.text as string); .map((part) => part.text as string);
if (textParts.length > 0) { if (textParts.length > 0) {
const text = textParts.join(''); const text = textParts.join('');
// 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');
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.
@ -2269,6 +2311,21 @@ export class TeamProvisioningService {
// 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) return;
// Prevent false "ready" when auth failure was printed as assistant text or logs
// but the filesystem monitor observed files on disk.
const authFailureText = [
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');
return;
}
run.provisioningComplete = true; run.provisioningComplete = true;
this.setLeadActivity(run, 'idle'); this.setLeadActivity(run, 'idle');
@ -3441,26 +3498,16 @@ export class TeamProvisioningService {
cwd: string, cwd: string,
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv
): Promise<{ warning?: string }> { ): Promise<{ warning?: string }> {
// Stage 1 + Stage 2 attempt #1 in parallel. // Stage 1: verify binary works (awaited first for clearer errors)
// Rationale: both are independent process spawns and the combined wall time // Important: keep this sequential with Stage 2 to avoid auth/credential-store races
// is dominated by startup/IO. We still prioritize the stage-1 error message. // when multiple `claude` processes start simultaneously (most visible on Windows).
const versionProbePromise = this.spawnProbe( const versionProbe = await this.spawnProbe(
claudePath, claudePath,
['--version'], ['--version'],
cwd, cwd,
env, env,
CLI_PREPARE_TIMEOUT_MS CLI_PREPARE_TIMEOUT_MS
); );
const pingAttempt1Promise = this.spawnProbe(
claudePath,
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
);
// Stage 1: verify binary works (awaited first for clearer errors)
const versionProbe = await versionProbePromise;
if (versionProbe.exitCode !== 0) { if (versionProbe.exitCode !== 0) {
const errorText = const errorText =
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
@ -3472,21 +3519,13 @@ export class TeamProvisioningService {
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) { for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null; let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
try { try {
pingProbe = pingProbe = await this.spawnProbe(
attempt === 1 claudePath,
? await pingAttempt1Promise ['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
: await this.spawnProbe( cwd,
claudePath, env,
[ PREFLIGHT_TIMEOUT_MS
'-p', );
'Reply with the single word PONG and nothing else',
'--output-format',
'text',
],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) { if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
@ -3524,13 +3563,24 @@ export class TeamProvisioningService {
if (isAuthFailure || pingProbe.exitCode !== 0) { if (isAuthFailure || pingProbe.exitCode !== 0) {
const hint = isAuthFailure const hint = isAuthFailure
? 'Claude CLI `-p` mode is not authenticated. ' + ? 'Claude CLI `-p` mode is not authenticated. ' +
'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' + 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
'then export it as CLAUDE_CODE_OAUTH_TOKEN.' + 'For automation/headless use, set ANTHROPIC_API_KEY or run `claude setup-token` ' +
'and export CLAUDE_CODE_OAUTH_TOKEN.' +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '') (attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
return { warning: hint }; return { warning: hint };
} }
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
const isPong = pongCandidate.toUpperCase() === 'PONG';
if (!isPong) {
return {
warning:
'Preflight ping completed but did not return the expected PONG. ' +
`Output: ${combinedOutput || '(empty)'}`,
};
}
if (attempt > 1) { if (attempt > 1) {
logger.info( logger.info(
`Preflight auth succeeded on attempt ${attempt} (previous attempt had auth failure)` `Preflight auth succeeded on attempt ${attempt} (previous attempt had auth failure)`

View file

@ -17,6 +17,8 @@ export interface ProvisioningProgressBlockProps {
title: string; title: string;
/** Optional status message */ /** Optional status message */
message?: string | null; message?: string | null;
/** Visual tone (e.g. highlight errors) */
tone?: 'default' | 'error';
/** Index of the current step in STEP_ORDER (0-based), or -1 if unknown */ /** Index of the current step in STEP_ORDER (0-based), or -1 if unknown */
currentStepIndex: number; currentStepIndex: number;
/** Show spinner next to title */ /** Show spinner next to title */
@ -66,6 +68,7 @@ function useElapsedTimer(startedAt?: string): string | null {
export const ProvisioningProgressBlock = ({ export const ProvisioningProgressBlock = ({
title, title,
message, message,
tone = 'default',
currentStepIndex, currentStepIndex,
loading = false, loading = false,
onCancel, onCancel,
@ -78,6 +81,7 @@ export const ProvisioningProgressBlock = ({
const elapsed = useElapsedTimer(startedAt); const elapsed = useElapsedTimer(startedAt);
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false);
const outputScrollRef = useRef<HTMLDivElement>(null); const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error';
// Auto-scroll assistant output // Auto-scroll assistant output
useEffect(() => { useEffect(() => {
@ -90,6 +94,7 @@ export const ProvisioningProgressBlock = ({
<div <div
className={cn( className={cn(
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2', 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
isError && 'border-red-500/40 bg-red-500/10',
className className
)} )}
> >
@ -119,7 +124,16 @@ export const ProvisioningProgressBlock = ({
</Button> </Button>
) : null} ) : null}
</div> </div>
{message ? <p className="mt-1.5 text-xs text-[var(--color-text-muted)]">{message}</p> : null} {message ? (
<p
className={cn(
'mt-1.5 text-xs',
isError ? 'text-red-200' : 'text-[var(--color-text-muted)]'
)}
>
{message}
</p>
) : null}
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5"> <div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { {STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isDone = currentStepIndex >= 0 && index < currentStepIndex;
@ -155,7 +169,10 @@ export const ProvisioningProgressBlock = ({
<p className="mb-1 text-[11px] font-medium text-[var(--color-text-muted)]">Live output</p> <p className="mb-1 text-[11px] font-medium text-[var(--color-text-muted)]">Live output</p>
<div <div
ref={outputScrollRef} ref={outputScrollRef}
className="max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2" className={cn(
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2',
isError && 'border-red-500/40'
)}
> >
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" /> <MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" />
</div> </div>

View file

@ -87,16 +87,29 @@ export const TeamProvisioningBanner = ({
if (isFailed) { if (isFailed) {
return ( return (
<div className="mb-3 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2"> <div className="mb-3">
<p className="flex-1 text-xs text-red-200">{progress.message}</p> <div className="mb-2 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2">
<Button <p className="flex-1 text-xs text-red-200">{progress.message}</p>
variant="outline" <Button
size="sm" variant="outline"
className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200" size="sm"
onClick={() => setDismissed(true)} className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200"
> onClick={() => setDismissed(true)}
<X size={12} /> >
</Button> <X size={12} />
</Button>
</div>
<ProvisioningProgressBlock
title="Launch failed"
message={progress.error ?? null}
tone="error"
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
startedAt={progress.startedAt}
pid={progress.pid}
cliLogsTail={progress.cliLogsTail}
assistantOutput={progress.assistantOutput}
onCancel={null}
/>
</div> </div>
); );
} }

View file

@ -40,4 +40,29 @@ describe('TeamMemberResolver', () => {
expect(lead?.role).toBe('lead'); expect(lead?.role).toBe('lead');
expect(lead?.agentType).toBe('team-lead'); expect(lead?.agentType).toBe('team-lead');
}); });
it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
{ name: 'user', agentType: 'general-purpose' },
],
};
const metaMembers: TeamConfig['members'] = [
{ name: 'user', agentType: 'general-purpose' },
{ name: 'alice', role: 'dev', agentType: 'general-purpose' },
];
const inboxNames = ['user', 'alice'];
const tasks: TeamTask[] = [];
const messages: InboxMessage[] = [];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
const names = members.map((m) => m.name);
expect(names).not.toContain('user');
expect(names).toContain('team-lead');
expect(names).toContain('alice');
});
}); });