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}"
declare -A FILES=(
["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-Setup.exe"]="Claude-Agent-Teams-UI-Setup-${VERSION}.exe"
["Claude-Agent-Teams-UI.AppImage"]="Claude-Agent-Teams-UI-${VERSION}.AppImage"
["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-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe"
["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-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm"
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman"

View file

@ -67,23 +67,23 @@ EOF
<table>
<tr>
<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" />
</a>
<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" />
</a>
</td>
<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" />
</a>
<br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td>
<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" />
</a>
<br />
@ -123,12 +123,12 @@ electron-builder generates these artifacts per platform:
| 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 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 x64 ZIP | `Claude-Agent-Teams-UI-<VER>-mac.zip` | — |
| 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` |
| 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 arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-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` |
| 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 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` |

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[] = [];
for (const name of names) {
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 SHELL_ENV_TIMEOUT_MS = 12000;
const CLI_PREPARE_TIMEOUT_MS = 10000;
const PROBE_CACHE_TTL_MS = 60_000;
const PREFLIGHT_TIMEOUT_MS = 30000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
@ -565,6 +566,7 @@ Constraints:
- Do NOT use TodoWrite.
- 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 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.
- 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.
@ -683,6 +685,7 @@ Constraints:
- Do NOT use TodoWrite.
- 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 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.
- 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.
@ -795,7 +798,12 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
const trimmed = buildCombinedLogs(stdoutText, stderrText).trim();
if (trimmed.length > 0) {
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);
}
@ -809,9 +817,9 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
interface CachedProbeResult {
claudePath: string;
env: NodeJS.ProcessEnv;
authSource: ProvisioningAuthSource;
warning?: string;
cachedAtMs: number;
}
let cachedProbeResult: CachedProbeResult | null = null;
@ -860,12 +868,21 @@ export class TeamProvisioningService {
async warmup(): Promise<void> {
try {
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) {
return;
}
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return;
const { env, authSource } = await this.buildProvisioningEnv();
const cwd = process.cwd();
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');
} catch (error) {
logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`);
@ -880,15 +897,21 @@ export class TeamProvisioningService {
}
if (cachedProbeResult) {
const { warning, authSource } = cachedProbeResult;
const warnings: string[] = [];
if (warning) warnings.push(warning);
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
return {
ready: !warning || authSource !== 'none' || !isAuthFailure,
message: 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
};
const ageMs = Date.now() - cachedProbeResult.cachedAtMs;
if (ageMs >= PROBE_CACHE_TTL_MS) {
cachedProbeResult = null;
} else {
const { warning, authSource } = cachedProbeResult;
const warnings: string[] = [];
if (warning) warnings.push(warning);
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();
@ -948,6 +971,19 @@ export class TeamProvisioningService {
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 {
ready: true,
message: 'CLI is warmed up and ready to launch',
@ -957,12 +993,15 @@ export class TeamProvisioningService {
private isAuthFailureWarning(text: string): boolean {
const lower = text.toLowerCase();
const has401 = /(^|\D)401(\D|$)/.test(lower);
return (
lower.includes('not authenticated') ||
lower.includes('not logged in') ||
lower.includes('please run /login') ||
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);
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
error:
'Claude CLI is not authenticated. Run `claude` in a terminal to complete login, ' +
'or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' +
'to authenticate, or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
@ -2138,6 +2177,9 @@ export class TeamProvisioningService {
.map((part) => part.text as string);
if (textParts.length > 0) {
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)}`);
// During provisioning (before provisioningComplete), accumulate for live UI preview.
// 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
// double-invocation from filesystem monitor + stream-json racing.
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;
this.setLeadActivity(run, 'idle');
@ -3441,26 +3498,16 @@ export class TeamProvisioningService {
cwd: string,
env: NodeJS.ProcessEnv
): Promise<{ warning?: string }> {
// Stage 1 + Stage 2 attempt #1 in parallel.
// Rationale: both are independent process spawns and the combined wall time
// is dominated by startup/IO. We still prioritize the stage-1 error message.
const versionProbePromise = this.spawnProbe(
// Stage 1: verify binary works (awaited first for clearer errors)
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
// when multiple `claude` processes start simultaneously (most visible on Windows).
const versionProbe = await this.spawnProbe(
claudePath,
['--version'],
cwd,
env,
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) {
const errorText =
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
@ -3472,21 +3519,13 @@ export class TeamProvisioningService {
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
try {
pingProbe =
attempt === 1
? await pingAttempt1Promise
: await this.spawnProbe(
claudePath,
[
'-p',
'Reply with the single word PONG and nothing else',
'--output-format',
'text',
],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
);
pingProbe = await this.spawnProbe(
claudePath,
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
@ -3524,13 +3563,24 @@ export class TeamProvisioningService {
if (isAuthFailure || pingProbe.exitCode !== 0) {
const hint = isAuthFailure
? 'Claude CLI `-p` mode is not authenticated. ' +
'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' +
'then export it as CLAUDE_CODE_OAUTH_TOKEN.' +
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
'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)` : '')
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
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) {
logger.info(
`Preflight auth succeeded on attempt ${attempt} (previous attempt had auth failure)`

View file

@ -17,6 +17,8 @@ export interface ProvisioningProgressBlockProps {
title: string;
/** Optional status message */
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 */
currentStepIndex: number;
/** Show spinner next to title */
@ -66,6 +68,7 @@ function useElapsedTimer(startedAt?: string): string | null {
export const ProvisioningProgressBlock = ({
title,
message,
tone = 'default',
currentStepIndex,
loading = false,
onCancel,
@ -78,6 +81,7 @@ export const ProvisioningProgressBlock = ({
const elapsed = useElapsedTimer(startedAt);
const [logsOpen, setLogsOpen] = useState(false);
const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error';
// Auto-scroll assistant output
useEffect(() => {
@ -90,6 +94,7 @@ export const ProvisioningProgressBlock = ({
<div
className={cn(
'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
)}
>
@ -119,7 +124,16 @@ export const ProvisioningProgressBlock = ({
</Button>
) : null}
</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">
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
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>
<div
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" />
</div>

View file

@ -87,16 +87,29 @@ export const TeamProvisioningBanner = ({
if (isFailed) {
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">
<p className="flex-1 text-xs text-red-200">{progress.message}</p>
<Button
variant="outline"
size="sm"
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>
<div className="mb-3">
<div className="mb-2 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2">
<p className="flex-1 text-xs text-red-200">{progress.message}</p>
<Button
variant="outline"
size="sm"
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>
</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>
);
}

View file

@ -40,4 +40,29 @@ describe('TeamMemberResolver', () => {
expect(lead?.role).toBe('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');
});
});