From d3e234c2d4e28caa07ff0bca0ad4b617ba6f5c7b Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 26 Feb 2026 22:12:53 +0200 Subject: [PATCH 01/37] fix: add retry logic to sendInboxMessage for concurrent writes On Windows, parallel writes to the same inbox file cause race conditions where atomicWrite verification fails (another process overwrites between write and verify). Added retry loop (8 attempts) matching the existing pattern in addTaskComment. Bumps teamctl version to 11. Fixes CI failure: test (windows-latest) "parallel messages to same inbox" --- .../services/team/TeamAgentToolsInstaller.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index eb0a0f01..60b3b767 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; const TOOL_FILE_NAME = 'teamctl.js'; -const TOOL_VERSION = 10; +const TOOL_VERSION = 11; function buildTeamCtlScript(): string { const script = String.raw`#!/usr/bin/env node @@ -420,15 +420,25 @@ function sendInboxMessage(paths, teamName, flags) { messageId, }; - const existing = readJson(inboxPath, []); - const list = Array.isArray(existing) ? existing : []; - list.push(payload); - atomicWrite(inboxPath, JSON.stringify(list, null, 2)); - const verify = readJson(inboxPath, []); - if (!Array.isArray(verify) || !verify.some((m) => m && m.messageId === messageId)) { - die('Inbox write verification failed'); + var lastErr; + for (var attempt = 0; attempt < 8; attempt++) { + try { + var existing = readJson(inboxPath, []); + var list = Array.isArray(existing) ? existing : []; + list.push(payload); + atomicWrite(inboxPath, JSON.stringify(list, null, 2)); + var verify = readJson(inboxPath, []); + if (Array.isArray(verify) && verify.some(function (m) { return m && m.messageId === messageId; })) { + return { deliveredToInbox: true, messageId: messageId }; + } + // Verification failed (concurrent write overwrote us) — retry + } catch (e) { + lastErr = e; + if (attempt === 7) throw e; + } } - return { deliveredToInbox: true, messageId }; + // If all retries exhausted without verification success, die + die('Inbox write verification failed after retries' + (lastErr ? ': ' + formatError(lastErr) : '')); } function reviewApprove(paths, teamName, taskId, flags) { From 6a8b9cd1b5404383f246114602db65ac5c64ad9f Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 12:56:32 +0200 Subject: [PATCH 02/37] fix: enhance CLI installer and session management - Updated the postinstall script in package.json to handle rebuild failures gracefully. - Added clearContext option in team launch requests to allow starting fresh sessions without resuming previous context. - Improved CLI installer logging by integrating raw output chunks for better terminal rendering. - Refactored components to utilize TerminalLogPanel for displaying installation logs, enhancing user experience during CLI installation. - Updated various services and hooks to support the new clearContext feature and raw logging. --- package.json | 2 +- src/main/ipc/teams.ts | 1 + .../infrastructure/CliInstallerService.ts | 27 ++++-- .../services/team/TeamProvisioningService.ts | 60 ++++++++------ .../components/dashboard/CliStatusBanner.tsx | 39 +-------- .../components/dashboard/DashboardView.tsx | 2 +- .../team/dialogs/LaunchTeamDialog.tsx | 34 +++++++- .../team/review/ScopeWarningBanner.tsx | 10 +-- .../components/terminal/TerminalLogPanel.tsx | 83 +++++++++++++++++++ src/renderer/hooks/useCliInstaller.ts | 6 +- src/renderer/store/index.ts | 5 +- .../store/slices/cliInstallerSlice.ts | 3 + src/shared/types/cliInstaller.ts | 2 + src/shared/types/team.ts | 2 + 14 files changed, 195 insertions(+), 81 deletions(-) create mode 100644 src/renderer/components/terminal/TerminalLogPanel.tsx diff --git a/package.json b/package.json index 2529b301..75a51af3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", "standalone:start": "node dist-standalone/index.cjs", "prepare": "husky", - "postinstall": "electron-rebuild -f -o node-pty" + "postinstall": "electron-rebuild -f -o node-pty || echo 'node-pty rebuild failed (terminal will be disabled)'" }, "lint-staged": { "src/**/*.{ts,tsx,js,jsx}": [ diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index df419c15..1c6a45a1 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -639,6 +639,7 @@ async function handleLaunchTeam( cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + clearContext: payload.clearContext === true ? true : undefined, }, (progress) => { try { diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index fc211670..35772582 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -331,7 +331,11 @@ export class CliInstallerService { await fsp.chmod(tmpFilePath, 0o755); } - this.sendProgress({ type: 'installing', detail: 'Starting shell integration...' }); + this.sendProgress({ + type: 'installing', + detail: 'Starting shell integration...', + rawChunk: 'Starting shell integration...\r\n', + }); logger.info('Running claude install...'); try { @@ -446,14 +450,19 @@ export class CliInstallerService { const outputLines: string[] = []; const handleOutput = (chunk: Buffer): void => { - const text = chunk.toString('utf-8').trim(); - if (!text) return; - for (const line of text.split('\n')) { - const trimmed = line.trim(); - if (trimmed) { - outputLines.push(trimmed); - logger.info(`[claude install] ${trimmed}`); - this.sendProgress({ type: 'installing', detail: trimmed }); + const raw = chunk.toString('utf-8'); + if (!raw.trim()) return; + + // Send raw chunk for xterm.js rendering in UI + this.sendProgress({ type: 'installing', rawChunk: raw }); + + // Extract clean text for logger and error context + for (const line of raw.split('\n')) { + // eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- ANSI escape sequences stripped for clean logs + const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim(); + if (clean) { + outputLines.push(clean); + logger.info(`[claude install] ${clean}`); } } }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 705c2c0b..8c1922ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1092,34 +1092,44 @@ export class TeamProvisioningService { // Extract leadSessionId for session resume on reconnect. // If a valid JSONL file exists for the previous session, we can resume it // so the lead retains full context of prior work. + // When clearContext is true, skip resume entirely to start a fresh session. let previousSessionId: string | undefined; - try { - const configParsed = JSON.parse(configRaw) as Record; - if ( - typeof configParsed.leadSessionId === 'string' && - configParsed.leadSessionId.trim().length > 0 - ) { - const candidateId = configParsed.leadSessionId.trim(); - const projectPath = - typeof configParsed.projectPath === 'string' && configParsed.projectPath.trim().length > 0 - ? configParsed.projectPath.trim() - : request.cwd; - const projectId = encodePath(projectPath); - const baseDir = extractBaseDir(projectId); - const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`); - if (await this.pathExists(jsonlPath)) { - previousSessionId = candidateId; - logger.info( - `[${request.teamName}] Found previous session JSONL for resume: ${candidateId}` - ); - } else { - logger.info( - `[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh` - ); + if (request.clearContext) { + logger.info( + `[${request.teamName}] clearContext requested — skipping session resume, starting fresh` + ); + } else { + try { + const configParsed = JSON.parse(configRaw) as Record; + if ( + typeof configParsed.leadSessionId === 'string' && + configParsed.leadSessionId.trim().length > 0 + ) { + const candidateId = configParsed.leadSessionId.trim(); + const projectPath = + typeof configParsed.projectPath === 'string' && + configParsed.projectPath.trim().length > 0 + ? configParsed.projectPath.trim() + : request.cwd; + const projectId = encodePath(projectPath); + const baseDir = extractBaseDir(projectId); + const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`); + if (await this.pathExists(jsonlPath)) { + previousSessionId = candidateId; + logger.info( + `[${request.teamName}] Found previous session JSONL for resume: ${candidateId}` + ); + } else { + logger.info( + `[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh` + ); + } } + } catch { + logger.debug( + `[${request.teamName}] Failed to extract leadSessionId from config for resume` + ); } - } catch { - logger.debug(`[${request.teamName}] Failed to extract leadSessionId from config for resume`); } // IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json. diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 96f2b22e..d9aef8e4 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -7,9 +7,10 @@ * Only rendered in Electron mode. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; +import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { formatBytes } from '@renderer/utils/formatters'; @@ -51,38 +52,6 @@ const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null ); }; -/** Mini log panel shown during the installing phase */ -const LogPanel = ({ logs }: { logs: string[] }): React.JSX.Element | null => { - const scrollRef = useRef(null); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [logs]); - - if (logs.length === 0) return null; - - return ( -
- {logs.map((line, i) => ( -
- {line} -
- ))} -
- ); -}; - /** Error display with multi-line support */ const ErrorDisplay = ({ error, @@ -151,7 +120,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { downloadTotal, installerError, installerDetail, - installerLogs, + installerRawChunks, completedVersion, fetchCliStatus, installCli, @@ -321,7 +290,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { Installing Claude CLI... - + ); } diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 93c412d0..6efb75bf 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -146,7 +146,7 @@ const RepositoryCard = ({ (e: React.MouseEvent) => { e.stopPropagation(); if (projectPath) { - void api.openPath(projectPath); + void api.openPath(projectPath, projectPath); } }, [projectPath] diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3eb65fdb..dc28aaa8 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; import { Dialog, DialogContent, @@ -24,7 +25,7 @@ import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; -import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from 'lucide-react'; import { ProjectPathSelector } from './ProjectPathSelector'; @@ -71,6 +72,7 @@ export const LaunchTeamDialog = ({ const [prepareWarnings, setPrepareWarnings] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedModel, setSelectedModel] = useState(''); + const [clearContext, setClearContext] = useState(false); const resetFormState = (): void => { setLocalError(null); @@ -82,6 +84,7 @@ export const LaunchTeamDialog = ({ setSelectedProjectPath(''); setCustomCwd(''); setSelectedModel(''); + setClearContext(false); }; // Warm up CLI on open @@ -244,6 +247,7 @@ export const LaunchTeamDialog = ({ cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined, + clearContext: clearContext || undefined, }); resetFormState(); onClose(); @@ -369,6 +373,34 @@ export const LaunchTeamDialog = ({ + +
+
+ setClearContext(checked === true)} + /> + +
+ {clearContext && ( +
+
+ +

+ The team lead will start a new session without resuming previous context. All + accumulated session memory and conversation history will not be available. +

+
+
+ )} +
{activeError ? ( diff --git a/src/renderer/components/team/review/ScopeWarningBanner.tsx b/src/renderer/components/team/review/ScopeWarningBanner.tsx index 502310eb..1eb44427 100644 --- a/src/renderer/components/team/review/ScopeWarningBanner.tsx +++ b/src/renderer/components/team/review/ScopeWarningBanner.tsx @@ -29,9 +29,9 @@ const TIER_CONFIGS: Record = { border: 'border-emerald-500/15', bg: 'bg-emerald-500/5', accentColor: 'text-emerald-400', - title: 'Scope determined precisely', + title: 'Task scope determined precisely', detail: - 'Both start (TaskUpdate → in_progress) and completion (TaskUpdate → completed) markers found in the session log. The diff includes only file modifications (Edit, Write) between these two boundaries.', + 'Both start and completion markers found in the session log. The diff includes only changes made during this specific task — other tasks that modified the same files are excluded.', }, 2: { Icon: Info, @@ -40,7 +40,7 @@ const TIER_CONFIGS: Record = { accentColor: 'text-blue-400', title: 'End boundary estimated', detail: - 'Only the start marker (TaskUpdate → in_progress) was found — the task has no completion marker yet. Changes shown from start marker to end of session log.', + 'Only the start marker was found — the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.', }, 3: { Icon: AlertTriangle, @@ -49,7 +49,7 @@ const TIER_CONFIGS: Record = { accentColor: 'text-orange-400', title: 'Start boundary estimated', detail: - 'Only the completion marker (TaskUpdate → completed) was found. The start of work was not captured — this can happen if the task was already in progress when the session began. Changes shown from session start to completion marker.', + 'Only the completion marker was found — the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.', }, 4: { Icon: AlertTriangle, @@ -58,7 +58,7 @@ const TIER_CONFIGS: Record = { accentColor: 'text-red-400', title: 'Showing all session changes', detail: - 'No TaskUpdate markers found in the session log. Cannot determine task-specific boundaries — this can happen with older CLI versions or non-standard workflows. All file modifications from the session are included.', + 'No task markers found in the session log. Cannot isolate this task — all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.', }, }; diff --git a/src/renderer/components/terminal/TerminalLogPanel.tsx b/src/renderer/components/terminal/TerminalLogPanel.tsx new file mode 100644 index 00000000..ea9d41c4 --- /dev/null +++ b/src/renderer/components/terminal/TerminalLogPanel.tsx @@ -0,0 +1,83 @@ +import '@xterm/xterm/css/xterm.css'; + +import { useEffect, useRef } from 'react'; + +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal } from '@xterm/xterm'; + +interface TerminalLogPanelProps { + /** Raw output chunks (with ANSI codes) to render */ + chunks: string[]; + /** CSS class for container */ + className?: string; +} + +export const TerminalLogPanel = ({ + chunks, + className, +}: TerminalLogPanelProps): React.JSX.Element => { + const containerRef = useRef(null); + const termRef = useRef(null); + const writtenRef = useRef(0); + + // Create xterm instance once + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const term = new Terminal({ + cursorBlink: false, + disableStdin: true, + fontSize: 12, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + scrollback: 200, + theme: { + background: '#141416', + foreground: '#fafafa', + cursor: 'transparent', + }, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(container); + + const rafId = requestAnimationFrame(() => fitAddon.fit()); + + const observer = new ResizeObserver(() => fitAddon.fit()); + observer.observe(container); + + termRef.current = term; + writtenRef.current = 0; + + return () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + term.dispose(); + termRef.current = null; + writtenRef.current = 0; + }; + }, []); + + // Write new chunks incrementally + useEffect(() => { + const term = termRef.current; + if (!term) return; + + for (let i = writtenRef.current; i < chunks.length; i++) { + term.write(chunks[i]); + } + writtenRef.current = chunks.length; + }, [chunks]); + + return ( +
+ ); +}; diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index c3a0dd5a..679d5e23 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -26,7 +26,7 @@ export function useCliInstaller(): { downloadTotal: number; installerError: string | null; installerDetail: string | null; - installerLogs: string[]; + installerRawChunks: string[]; completedVersion: string | null; fetchCliStatus: () => Promise; installCli: () => void; @@ -41,7 +41,7 @@ export function useCliInstaller(): { const downloadTotal = useStore((s) => s.cliDownloadTotal); const installerError = useStore((s) => s.cliInstallerError); const installerDetail = useStore((s) => s.cliInstallerDetail); - const installerLogs = useStore((s) => s.cliInstallerLogs); + const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); const completedVersion = useStore((s) => s.cliCompletedVersion); const fetchCliStatus = useStore((s) => s.fetchCliStatus); const installCli = useStore((s) => s.installCli); @@ -58,7 +58,7 @@ export function useCliInstaller(): { downloadTotal, installerError, installerDetail, - installerLogs, + installerRawChunks, completedVersion, fetchCliStatus, installCli, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index a97e396d..2c2ccc9b 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -399,13 +399,16 @@ export function initializeNotificationListeners(): () => void { useStore.setState({ cliInstallerState: 'verifying', cliInstallerDetail: detail }); break; case 'installing': { - // Accumulate log lines for the mini-terminal + // Accumulate log lines and raw chunks for xterm.js rendering const prevLogs = useStore.getState().cliInstallerLogs; + const prevRaw = useStore.getState().cliInstallerRawChunks; const newLogs = detail ? [...prevLogs, detail].slice(-50) : prevLogs; + const newRaw = progress.rawChunk ? [...prevRaw, progress.rawChunk].slice(-200) : prevRaw; useStore.setState({ cliInstallerState: 'installing', cliInstallerDetail: detail, cliInstallerLogs: newLogs, + cliInstallerRawChunks: newRaw, }); break; } diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 0c7073ba..e66a8294 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -37,6 +37,7 @@ export interface CliInstallerSlice { cliInstallerError: string | null; cliInstallerDetail: string | null; cliInstallerLogs: string[]; + cliInstallerRawChunks: string[]; cliCompletedVersion: string | null; // Actions @@ -62,6 +63,7 @@ export const createCliInstallerSlice: StateCreator { @@ -84,6 +86,7 @@ export const createCliInstallerSlice: StateCreator Date: Fri, 27 Feb 2026 13:25:07 +0200 Subject: [PATCH 03/37] fix: update MemberBadge and LaunchTeamDialog components for improved functionality - Modified MemberBadge to display 'lead' for team leads instead of the full name. - Refactored LaunchTeamDialog to simplify model selection logic and replace the Select component with a custom button-based interface for better user experience. - Enhanced KanbanTaskCard to include meta actions for task management, improving the layout and functionality for manual review tasks. --- src/renderer/components/team/MemberBadge.tsx | 2 +- .../team/dialogs/LaunchTeamDialog.tsx | 43 ++++++----- .../components/team/kanban/KanbanTaskCard.tsx | 73 +++++++++++-------- 3 files changed, 66 insertions(+), 52 deletions(-) diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index 08dd2ada..e3dfaaa2 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -45,7 +45,7 @@ export const MemberBadge = ({ className={`rounded px-1.5 py-0.5 ${textClass} font-medium tracking-wide`} style={badgeStyle} > - {name} + {name === 'team-lead' ? 'lead' : name} ); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index dc28aaa8..034dc872 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -13,14 +13,8 @@ import { } from '@renderer/components/ui/dialog'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -246,7 +240,7 @@ export const LaunchTeamDialog = ({ teamName, cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, - model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined, + model: selectedModel || undefined, clearContext: clearContext || undefined, }); resetFormState(); @@ -361,17 +355,28 @@ export const LaunchTeamDialog = ({
- +
+ {[ + { value: '', label: 'Default' }, + { value: 'opus', label: 'Opus 4.6' }, + { value: 'sonnet', label: 'Sonnet 4.5' }, + { value: 'haiku', label: 'Haiku 4.5' }, + ].map((opt) => ( + + ))} +
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index a778efc5..5b6ecc68 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -170,6 +170,40 @@ export const KanbanTaskCard = ({ } }, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]); + const isReviewManual = columnId === 'review' && !hasReviewers; + + const metaActions = ( + <> + {showChangesColumn && taskHasChanges === true ? ( + + ) : null} + + {onDeleteTask ? ( + + ) : null} + + ); + return (
- {!hasReviewers ? ( -

Manual review

+
+ {isReviewManual ? ( +
+

Manual review

+
{metaActions}
+
) : null}
- ) : null} - - {onDeleteTask ? ( - - ) : null} -
+ {!isReviewManual ?
{metaActions}
: null}
); From f0c2677c6af16dfc3aaa333882828fe23f3ac035 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 14:09:02 +0200 Subject: [PATCH 04/37] feat: auto-publish releases with stable download links - Change releaseType from draft to release for auto-publishing - Add upload-stable-links job to create version-agnostic asset copies - Update README with direct download URLs per platform - Add Requirements section to Installation - Remove downloads/platform badges - Add docs/RELEASE.md with versioning and release guide - Move community docs to .github/ --- CLA.md => .github/CLA.md | 0 .../CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md => .github/CONTRIBUTING.md | 0 SECURITY.md => .github/SECURITY.md | 0 .github/workflows/release.yml | 38 +++++ README.md | 27 +-- docs/RELEASE.md | 154 ++++++++++++++++++ package.json | 2 +- 8 files changed, 208 insertions(+), 15 deletions(-) rename CLA.md => .github/CLA.md (100%) rename CODE_OF_CONDUCT.md => .github/CODE_OF_CONDUCT.md (92%) rename CONTRIBUTING.md => .github/CONTRIBUTING.md (100%) rename SECURITY.md => .github/SECURITY.md (100%) create mode 100644 docs/RELEASE.md diff --git a/CLA.md b/.github/CLA.md similarity index 100% rename from CLA.md rename to .github/CLA.md diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 92% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md index c92708e3..0773f337 100644 --- a/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -17,4 +17,4 @@ This project follows the Contributor Covenant Code of Conduct. Maintainers are here to help keep our community welcoming. They’ll clarify expectations when needed and, if necessary, take steps to address behavior that goes against these standards. ## Reporting -Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`. +Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md` (`.github/SECURITY.md`). diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 100% rename from SECURITY.md rename to .github/SECURITY.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ea492e1..87e51c85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -218,3 +218,41 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm dist:linux + + upload-stable-links: + needs: [release-mac, release-win, release-linux] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Upload stable-named assets for /latest/download links + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + REPO="${GITHUB_REPOSITORY}" + 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-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" + ) + + # Remove old stable assets (ignore errors if they don't exist) + for STABLE_NAME in "${!FILES[@]}"; do + gh release delete-asset "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --yes 2>/dev/null || true + done + + # Download versioned files and re-upload with stable names + for STABLE_NAME in "${!FILES[@]}"; do + VERSIONED_NAME="${FILES[$STABLE_NAME]}" + echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}" + curl -fSL -o "$STABLE_NAME" "${DOWNLOAD_BASE}/${VERSIONED_NAME}" && \ + gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber + rm -f "$STABLE_NAME" + done diff --git a/README.md b/README.md index b8b8bfaa..ac8a6d10 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,19 @@

Latest Release  - CI Status  - Downloads  - Platform + CI Status


- + Download for macOS    - + Download for Linux    - + Download for Windows

@@ -47,16 +45,19 @@ ## Installation +### Requirements + +None. Claude Code is **not required** to be installed beforehand — the app includes a built-in installer and login, so you can set up everything directly from the app. + ### Direct Download | Platform | Download | Notes | |----------|----------|-------| -| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `arm64` asset. Drag to Applications. On first launch: right-click → Open | -| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open | -| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). | -| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" | +| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg) | Drag to Applications. On first launch: right-click → Open | +| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg) | Drag to Applications. On first launch: right-click → Open | +| **Linux** | [`.AppImage`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage) / [`.deb`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb) / [`.rpm`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm) / [`.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.pacman) | Choose the package format for your distro | +| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" | -The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login. --- @@ -114,11 +115,11 @@ pnpm dist # macOS + Windows + Linux ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md). +See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](.github/CODE_OF_CONDUCT.md). ## Security -IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](SECURITY.md) for details. +IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](.github/SECURITY.md) for details. ## License diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 00000000..8a1a4233 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,154 @@ +# Release Guide + +## Versioning (SemVer) + +Format: `MAJOR.MINOR.PATCH` + +| Bump | When | Example | +|---------|-------------------------------------------------------------|------------------| +| MAJOR | Breaking changes, major UI overhaul, incompatible data format changes | 1.0.0 → 2.0.0 | +| MINOR | New features, new panels/views, new integrations | 1.0.0 → 1.1.0 | +| PATCH | Bug fixes, performance improvements, small UI tweaks | 1.0.0 → 1.0.1 | + +## Release Process + +### 1. Prepare + +```bash +# Make sure branch is clean and pushed +git status +git push origin +``` + +### 2. Create tag and push + +```bash +git tag v +git push origin v +``` + +This triggers the `release.yml` GitHub Actions workflow which: +- Builds the app (ubuntu) +- Packages macOS arm64 + x64 (with code signing & notarization) +- Packages Windows (NSIS installer) +- Packages Linux (AppImage, deb, rpm, pacman) +- Creates a GitHub Release with all artifacts + +### 3. Update release notes + +After the workflow completes, edit the release notes: + +```bash +gh release edit v --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF' + +EOF +)" +``` + +## Release Notes Template + +```markdown +## Claude Agent Teams UI v + +<1-2 sentence summary of the release> + +### What's New +- feat: +- feat: + +### Improvements +- improve: + +### Bug Fixes +- fix: + +### Downloads + +| Platform | File | +|----------|------| +| macOS (Apple Silicon) | [Claude-Agent-Teams-UI--arm64.dmg](https://github.com/777genius/claude_agent_teams_ui/releases/download/v/Claude-Agent-Teams-UI--arm64.dmg) | +| macOS (Intel) | [Claude-Agent-Teams-UI-.dmg](https://github.com/777genius/claude_agent_teams_ui/releases/download/v/Claude-Agent-Teams-UI-.dmg) | +| Windows | [Claude-Agent-Teams-UI-Setup-.exe](https://github.com/777genius/claude_agent_teams_ui/releases/download/v/Claude-Agent-Teams-UI-Setup-.exe) | +| Linux (AppImage) | [Claude-Agent-Teams-UI-.AppImage](https://github.com/777genius/claude_agent_teams_ui/releases/download/v/Claude-Agent-Teams-UI-.AppImage) | +| Linux (deb) | [claude-agent-teams-ui__amd64.deb](https://github.com/777genius/claude_agent_teams_ui/releases/download/v/claude-agent-teams-ui__amd64.deb) | +| Linux (rpm) | [claude-agent-teams-ui-.x86_64.rpm](https://github.com/777genius/claude_agent_teams_ui/releases/download/v/claude-agent-teams-ui-.x86_64.rpm) | +| Linux (pacman) | [claude-agent-teams-ui-.pacman](https://github.com/777genius/claude_agent_teams_ui/releases/download/v/claude-agent-teams-ui-.pacman) | +``` + +## Changelog Guidelines + +Write changelog entries from the **user's perspective**, not the developer's. + +**Good:** +- "Add team member activity timeline with live status tracking" +- "Fix crash when opening sessions with corrupted JSONL data" +- "Improve session list loading speed by 3x with streaming parser" + +**Bad:** +- "Refactor ChunkBuilder to use new pipeline" +- "Update dependencies" +- "Fix bug in useEffect cleanup" + +Group entries by type: `What's New` > `Improvements` > `Bug Fixes` > `Breaking Changes` (if any). + +## File Naming Convention + +electron-builder generates these artifacts per platform: + +| Platform | Versioned Name | Stable Name (for /latest/download) | +|------------------|--------------------------------------------------|--------------------------------------------| +| macOS arm64 DMG | `Claude-Agent-Teams-UI--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | +| macOS x64 DMG | `Claude-Agent-Teams-UI-.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | +| macOS arm64 ZIP | `Claude-Agent-Teams-UI--arm64-mac.zip` | — | +| macOS x64 ZIP | `Claude-Agent-Teams-UI--mac.zip` | — | +| Windows | `Claude-Agent-Teams-UI-Setup-.exe` | `Claude-Agent-Teams-UI-Setup.exe` | +| Linux AppImage | `Claude-Agent-Teams-UI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` | +| Linux deb | `claude-agent-teams-ui__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | +| Linux rpm | `claude-agent-teams-ui-.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` | +| Linux pacman | `claude-agent-teams-ui-.pacman` | `Claude-Agent-Teams-UI.pacman` | + +## Stable Download Links + +The `upload-stable-links` job in `release.yml` re-uploads key assets with version-agnostic names. +This enables permanent links in README that always point to the latest release: + +``` +https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg +``` + +GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version. + +## macOS Code Signing + +macOS builds are signed and notarized via GitHub Actions secrets: + +| Secret | Description | +|-------------------------------|------------------------------| +| `CSC_LINK` | Base64-encoded .p12 certificate | +| `CSC_KEY_PASSWORD` | Certificate password | +| `APPLE_ID` | Apple Developer account email | +| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password from appleid.apple.com | +| `APPLE_TEAM_ID` | Apple Developer Team ID | + +Without these secrets, macOS builds will be unsigned (users need to bypass Gatekeeper manually). + +## Auto-Update + +electron-builder generates `latest-mac.yml`, `latest.yml`, `latest-linux.yml` alongside release artifacts. These files enable the built-in auto-updater — users get notified when a new version is available. + +## Quick Reference + +```bash +# Create and publish a release +git tag v1.1.0 +git push origin v1.1.0 +# Wait for CI to finish (~10 min), then update notes + +# Delete a release (if needed) +gh release delete v1.1.0 --repo 777genius/claude_agent_teams_ui --yes +git tag -d v1.1.0 +git push origin :refs/tags/v1.1.0 + +# Check workflow status +gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3 +``` diff --git a/package.json b/package.json index 75a51af3..679f1dfb 100644 --- a/package.json +++ b/package.json @@ -242,7 +242,7 @@ "publish": [ { "provider": "github", - "releaseType": "draft" + "releaseType": "release" } ] }, From 17e20847d6f8947a6b5bc7f3a643fa6bda004490 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 14:44:02 +0200 Subject: [PATCH 05/37] improvemtns --- package.json | 1 + pnpm-lock.yaml | 8 ++++++++ .../components/terminal/EmbeddedTerminal.tsx | 20 +++++++++++++++++++ .../components/terminal/TerminalLogPanel.tsx | 8 ++++++++ 4 files changed, 37 insertions(+) diff --git a/package.json b/package.json index 679f1dfb..f3965ad1 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-virtual": "^3.10.8", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ecedb1d..2f77cab5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 + '@xterm/addon-web-links': + specifier: ^0.12.0 + version: 0.12.0 '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 @@ -2133,6 +2136,9 @@ packages: '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + '@xterm/addon-web-links@0.12.0': + resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} @@ -7624,6 +7630,8 @@ snapshots: '@xterm/addon-fit@0.11.0': {} + '@xterm/addon-web-links@0.12.0': {} + '@xterm/xterm@6.0.0': {} abbrev@1.1.1: {} diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx index f30994cd..57d15f48 100644 --- a/src/renderer/components/terminal/EmbeddedTerminal.tsx +++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { api } from '@renderer/api'; import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; import { Terminal } from '@xterm/xterm'; import type { PtySpawnOptions } from '@shared/types/terminal'; @@ -51,11 +52,30 @@ export const EmbeddedTerminal = ({ const fitAddon = new FitAddon(); term.loadAddon(fitAddon); + + // Clickable URLs — opens in external browser + const webLinksAddon = new WebLinksAddon((_event, uri) => { + void api.openExternal(uri); + }); + term.loadAddon(webLinksAddon); + term.open(container); // Fit after opening so dimensions are correct const rafId = requestAnimationFrame(() => fitAddon.fit()); + // Ctrl+C with selection → copy to clipboard (instead of sending SIGINT) + term.attachCustomKeyEventHandler((event) => { + if (event.type === 'keydown' && event.key === 'c' && (event.ctrlKey || event.metaKey)) { + const selection = term.getSelection(); + if (selection) { + void navigator.clipboard.writeText(selection); + return false; // Prevent sending to PTY + } + } + return true; + }); + // User input → PTY (returns IDisposable — must dispose in cleanup) const inputDisposable = term.onData((data) => { if (ptyId) api.terminal.write(ptyId, data); diff --git a/src/renderer/components/terminal/TerminalLogPanel.tsx b/src/renderer/components/terminal/TerminalLogPanel.tsx index ea9d41c4..7d769d81 100644 --- a/src/renderer/components/terminal/TerminalLogPanel.tsx +++ b/src/renderer/components/terminal/TerminalLogPanel.tsx @@ -2,7 +2,9 @@ import '@xterm/xterm/css/xterm.css'; import { useEffect, useRef } from 'react'; +import { api } from '@renderer/api'; import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; import { Terminal } from '@xterm/xterm'; interface TerminalLogPanelProps { @@ -40,6 +42,12 @@ export const TerminalLogPanel = ({ const fitAddon = new FitAddon(); term.loadAddon(fitAddon); + + const webLinksAddon = new WebLinksAddon((_event, uri) => { + void api.openExternal(uri); + }); + term.loadAddon(webLinksAddon); + term.open(container); const rafId = requestAnimationFrame(() => fitAddon.fit()); From 427c5478e1ec1e6fcea82953ecaf2895ccbf9b0c Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 15:37:37 +0200 Subject: [PATCH 06/37] improvement --- README.md | 2 +- .../infrastructure/CliInstallerService.ts | 39 ++++++++++++++++++- .../infrastructure/PtyTerminalService.ts | 4 +- src/main/utils/pathDecoder.ts | 37 +++++++++++++++++- src/main/utils/pathValidation.ts | 9 ++--- 5 files changed, 79 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ac8a6d10..42c3464f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

- 100% free, open source. No API keys. No configuration. Just download, open, and see everything Claude Code did. + 100% free, open source. No API keys. No configuration.


diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 35772582..9af488fa 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -58,6 +58,12 @@ const INSTALL_TIMEOUT_MS = 120_000; /** Max redirects to follow when fetching from GCS */ const MAX_REDIRECTS = 5; +/** Max retries for EBUSY (antivirus scanning the new binary) */ +const EBUSY_MAX_RETRIES = 3; + +/** Delay between EBUSY retries (multiplied by attempt number) */ +const EBUSY_RETRY_DELAY_MS = 2000; + // ============================================================================= // Helpers // ============================================================================= @@ -331,6 +337,12 @@ export class CliInstallerService { await fsp.chmod(tmpFilePath, 0o755); } + // On Windows, antivirus (Defender) scans new executables on first access. + // A brief pause lets the scan complete before we spawn, preventing EBUSY. + if (process.platform === 'win32') { + await new Promise((r) => setTimeout(r, 1000)); + } + this.sendProgress({ type: 'installing', detail: 'Starting shell integration...', @@ -411,7 +423,11 @@ export class CliInstallerService { }); res.on('end', () => { - fileStream.end(() => resolve(hash.digest('hex'))); + const digest = hash.digest('hex'); + fileStream.end(); + // Wait for 'close' (not just 'finish') — ensures file descriptor is fully released. + // On Windows, spawning the file before 'close' can cause EBUSY. + fileStream.on('close', () => resolve(digest)); }); res.on('error', (err) => { @@ -429,8 +445,9 @@ export class CliInstallerService { /** * Run `claude install` via spawn with streaming output. * Collects all output for error context. Non-zero exit tolerated if binary resolves. + * Retries on EBUSY (antivirus scanning the new binary). */ - private async runInstallWithStreaming(binaryPath: string): Promise { + private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise { return new Promise((resolve, reject) => { const child = spawn(binaryPath, ['install'], { env: { ...process.env, CLAUDE_SKIP_ANALYTICS: '1' }, @@ -491,6 +508,24 @@ export class CliInstallerService { child.on('error', (err) => { clearTimeout(timeout); + + // EBUSY: antivirus (Windows Defender / macOS Gatekeeper) may be scanning the binary — retry + const isEbusy = (err as NodeJS.ErrnoException).code === 'EBUSY'; + if (isEbusy && attempt < EBUSY_MAX_RETRIES) { + const delayMs = attempt * EBUSY_RETRY_DELAY_MS; + logger.warn( + `spawn EBUSY (attempt ${attempt}/${EBUSY_MAX_RETRIES}), retrying in ${delayMs}ms...` + ); + this.sendProgress({ + type: 'installing', + rawChunk: `\r\n⏳ File busy (OS scan), retrying in ${delayMs / 1000}s...\r\n`, + }); + setTimeout(() => { + this.runInstallWithStreaming(binaryPath, attempt + 1).then(resolve, reject); + }, delayMs); + return; + } + reject(err); }); }); diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index f03bd429..55f02753 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -6,8 +6,8 @@ */ import crypto from 'node:crypto'; -import os from 'node:os'; +import { getHomeDir } from '@main/utils/pathDecoder'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; @@ -62,7 +62,7 @@ export class PtyTerminalService { name: 'xterm-256color', cols: options?.cols ?? 80, rows: options?.rows ?? 24, - cwd: options?.cwd ?? os.homedir(), + cwd: options?.cwd ?? getHomeDir(), env: { ...process.env, ...options?.env } as Record, }); diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 07478cca..4b37b158 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -223,9 +223,42 @@ export function buildTodoPath(claudeBasePath: string, sessionId: string): string // ============================================================================= /** - * Get the user's home directory. + * Try Electron's app.getPath('home') which correctly handles Unicode paths + * on Windows (Cyrillic, CJK, etc.) unlike Node's os.homedir() / env vars + * that can suffer from UTF-8 vs system codepage mismatches. + * + * Returns null when Electron app is unavailable (e.g. in tests). */ -function getHomeDir(): string { +function getElectronHome(): string | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Lazy require to avoid hard dependency on electron in test environments + const electron = require('electron') as { + app?: { getPath: (name: string) => string }; + }; + const app = electron.app; + if (app && typeof app.getPath === 'function') { + const home = app.getPath('home'); + if (home) return home; + } + } catch { + // Not in Electron context (tests, standalone builds, etc.) + } + return null; +} + +/** + * Get the user's home directory. + * + * Priority: + * 1. Electron app.getPath('home') — correct Unicode handling on all platforms + * 2. HOME env var (Unix) / USERPROFILE (Windows) + * 3. HOMEDRIVE + HOMEPATH (Windows fallback) + * 4. os.homedir() (Node.js built-in) + */ +export function getHomeDir(): string { + const electronHome = getElectronHome(); + if (electronHome) return electronHome; + const windowsHome = process.env.HOMEDRIVE && process.env.HOMEPATH ? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index dc9c6d34..4908e22e 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -6,10 +6,9 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { getClaudeBasePath } from './pathDecoder'; +import { getClaudeBasePath, getHomeDir } from './pathDecoder'; /** * Sensitive file patterns that should never be accessible. @@ -149,7 +148,7 @@ export function validateFilePath( // Expand ~ to home directory const expandedPath = filePath.startsWith('~') - ? path.join(os.homedir(), filePath.slice(1)) + ? path.join(getHomeDir(), filePath.slice(1)) : filePath; // Must be absolute path @@ -212,7 +211,7 @@ export function validateOpenPathUserSelected(targetPath: string): PathValidation } const expandedPath = targetPath.startsWith('~') - ? path.join(os.homedir(), targetPath.slice(1)) + ? path.join(getHomeDir(), targetPath.slice(1)) : targetPath; const normalizedPath = path.resolve(path.normalize(expandedPath)); @@ -256,7 +255,7 @@ export function validateOpenPath( // Expand ~ to home directory const expandedPath = targetPath.startsWith('~') - ? path.join(os.homedir(), targetPath.slice(1)) + ? path.join(getHomeDir(), targetPath.slice(1)) : targetPath; const normalizedPath = path.resolve(path.normalize(expandedPath)); From 35b23fc784bee5b57f698a6e4494f006ccc25cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9?= Date: Fri, 27 Feb 2026 16:58:05 +0300 Subject: [PATCH 07/37] fix: handle Windows spawn EINVAL on non-ASCII paths and add helper utilities --- src/main/utils/childProcess.ts | 99 ++++++++++++++++ .../CliInstallerService.test.ts | 24 ++++ .../team/TeamProvisioningService.test.ts | 38 ++++++ test/main/utils/childProcess.test.ts | 108 ++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 src/main/utils/childProcess.ts create mode 100644 test/main/services/team/TeamProvisioningService.test.ts create mode 100644 test/main/utils/childProcess.test.ts diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts new file mode 100644 index 00000000..18474b16 --- /dev/null +++ b/src/main/utils/childProcess.ts @@ -0,0 +1,99 @@ +import { spawn, execFile, exec, SpawnOptions, ExecFileOptions } from 'child_process'; +import { promisify } from 'util'; + +// re-exported helpers used throughout the codebase +export const execFileAsync = promisify(execFile); +export const execAsync = promisify(exec); + +/** + * Returns true if the string contains any non-ASCII character. + */ +function containsNonAscii(str: string): boolean { + return /[^\x00-\x7F]/.test(str); +} + +/** + * On Windows, creating a process whose *path* contains non-ASCII + * characters will often fail with `spawn EINVAL`. Detect that case so + * callers can automatically fall back to launching via a shell. + */ +function needsShell(binaryPath: string): boolean { + if (process.platform !== 'win32') return false; + if (!binaryPath) return false; + return containsNonAscii(binaryPath); +} + +/** + * Minimal quoting for command‑line arguments when building a shell + * invocation. We only escape spaces and double quotes since our + * callers only ever use simple strings (paths, flags, literals) and + * the shell itself will handle most quoting rules. + */ +function quoteArg(arg: string): string { + if (/[^A-Za-z0-9_\-\/.]/.test(arg)) { + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; +} + +/** + * Execute a CLI binary, falling back to running the command through a + * shell on Windows if the normal path-based spawn fails. `binaryPath` + * may be `null` which causes `claude` (lookup via PATH) to be used. + * + * The return value matches the shape of Node's `execFile` promise: an + * object with `stdout` and `stderr` strings. + */ +export async function execCli( + binaryPath: string | null, + args: string[], + options: ExecFileOptions = {} +): Promise<{ stdout: string; stderr: string }> { + const target = binaryPath || 'claude'; + + // attempt the normal execFile path first + if (!needsShell(target)) { + try { + const result = await execFileAsync(target, args, options); + return { stdout: String(result.stdout), stderr: String(result.stderr) }; + } catch (err: any) { + // fall through to shell fallback only when the error matches the + // Windows "invalid argument" problem; otherwise rethrow. + if (!(err && err.code === 'EINVAL')) { + throw err; + } + } + } + + // shell fallback (Windows only; others shouldn't reach here) + const cmd = [target, ...args].map(quoteArg).join(' '); + const shellResult = await execAsync(cmd, options as unknown as import('child_process').ExecOptions); + return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) }; +} + +/** + * Spawn a child process. If the initial `spawn()` call throws + * synchronously with EINVAL on Windows, retry using a shell-based + * command string. The returned `ChildProcess` is whatever the + * underlying call returned; listeners may safely be attached to it. + */ +export function spawnCli( + binaryPath: string, + args: string[], + options: SpawnOptions = {} +) { + if (process.platform === 'win32' && needsShell(binaryPath)) { + const cmd = [binaryPath, ...args].map(quoteArg).join(' '); + return spawn(cmd, { shell: true, ...options }); + } + + try { + return spawn(binaryPath, args, options); + } catch (err: any) { + if (process.platform === 'win32' && err && err.code === 'EINVAL') { + const cmd = [binaryPath, ...args].map(quoteArg).join(' '); + return spawn(cmd, { shell: true, ...options }); + } + throw err; + } +} diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index c31a1d8b..099a082c 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -105,6 +105,30 @@ describe('CliInstallerService', () => { // Version will be null because execFile is mocked to no-op // and latestVersion will be null because fetch is mocked }); + + it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => { + allowConsoleLogs(); + const fakePath = 'C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd'; + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(fakePath); + + // mock execFile to throw EINVAL first + const err: any = new Error('spawn EINVAL'); + err.code = 'EINVAL'; + const childProcess = await import('child_process'); + vi.spyOn(childProcess, 'execFile').mockImplementation((cmd, args, opts, cb) => { + cb(err, '', ''); + return {} as any; + }); + // mock exec to succeed as fallback + vi.spyOn(childProcess, 'exec').mockImplementation((cmd, opts, cb) => { + cb(null, '2.3.4', ''); + return {} as any; + }); + + const status = await service.getStatus(); + expect(status.installed).toBe(true); + expect(status.installedVersion).toBe('2.3.4'); + }); }); describe('install mutex', () => { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts new file mode 100644 index 00000000..37b4ac0d --- /dev/null +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ + ClaudeBinaryResolver: { resolve: vi.fn() }, +})); + +vi.mock('@main/utils/childProcess', () => ({ + spawnCli: vi.fn(), +})); + +import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { spawnCli } from '@main/utils/childProcess'; + +function allowConsoleLogs() { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +} + +describe('TeamProvisioningService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('warmup', () => { + it('does not throw when spawnCli rejects', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('C:\\path\\claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('spawn EINVAL'); + }); + + const svc = new TeamProvisioningService(); + await expect(svc.warmup()).resolves.not.toThrow(); + expect(spawnCli).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts new file mode 100644 index 00000000..2ead7909 --- /dev/null +++ b/test/main/utils/childProcess.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the entire child_process module so that we can inspect how our helpers +// invoke spawn/exec without hitting the real filesystem or spawning anything. +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execFile: vi.fn(), + exec: vi.fn(), +})); + +// Import after the mock call so that the mocked module is returned. +import * as child from 'child_process'; +import { spawnCli, execCli } from '@main/utils/childProcess'; + +// Helper to temporarily override process.platform +function setPlatform(value: string) { + Object.defineProperty(process, 'platform', { + value, + }); +} + +// restore platform after tests +const originalPlatform = process.platform; + +describe('cli child process helpers', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + describe('spawnCli', () => { + it('calls spawn directly when path is ascii on windows', () => { + setPlatform('win32'); + (child.spawn as unknown as vi.Mock).mockReturnValue({} as any); + + const result = spawnCli('C:\\bin\\claude.exe', ['--version'], { cwd: 'x' }); + expect(child.spawn).toHaveBeenCalledWith('C:\\bin\\claude.exe', ['--version'], { cwd: 'x' }); + expect(result).toEqual({} as any); + }); + + it('falls back to shell when spawn throws EINVAL', () => { + setPlatform('win32'); + const error: any = new Error('spawn EINVAL'); + error.code = 'EINVAL'; + const fake = {} as any; + const spawnMock = child.spawn as unknown as vi.Mock; + spawnMock.mockImplementationOnce(() => { + throw error; + }); + spawnMock.mockImplementationOnce(() => fake); + + const result = spawnCli('C:\\Users\\\\AppData\\Roaming\\npm\\claude.cmd', ['a', 'b'], { + env: { FOO: 'bar' }, + }); + expect(spawnMock).toHaveBeenCalledTimes(2); + const secondArg0 = spawnMock.mock.calls[1][0] as string; + expect(secondArg0).toMatch(/claude\.cmd/); + expect(spawnMock.mock.calls[1][2]).toMatchObject({ shell: true, env: { FOO: 'bar' } }); + expect(result).toBe(fake); + }); + + it('does not use shell when not on windows', () => { + setPlatform('linux'); + (child.spawn as unknown as vi.Mock).mockReturnValue({} as any); + const result = spawnCli('/usr/bin/claude', ['--help']); + expect(child.spawn).toHaveBeenCalledWith('/usr/bin/claude', ['--help'], {}); + expect(result).toEqual({} as any); + }); + }); + + describe('execCli', () => { + it('invokes execFile when path is normal', async () => { + setPlatform('win32'); + const execFileMock = child.execFile as unknown as vi.Mock; + execFileMock.mockImplementation((cmd, args, opts, cb) => { + cb(null, 'ok', ''); + return {} as any; + }); + const result = await execCli('C:\\bin\\claude.exe', ['--version']); + expect(execFileMock).toHaveBeenCalledWith('C:\\bin\\claude.exe', ['--version'], {}, expect.any(Function)); + expect(result.stdout).toBe('ok'); + }); + + it('falls back to exec shell when execFile throws EINVAL or path contains non-ascii', async () => { + setPlatform('win32'); + const execFileMock = child.execFile as unknown as vi.Mock; + execFileMock.mockImplementation((cmd, args, opts, cb) => { + const err: any = new Error('spawn EINVAL'); + err.code = 'EINVAL'; + cb(err, '', ''); + return {} as any; + }); + const execMock = child.exec as unknown as vi.Mock; + execMock.mockImplementation((cmd, opts, cb) => { + cb(null, '1.2.3', ''); + return {} as any; + }); + + const result = await execCli('C:\\Users\\\\AppData\\Roaming\\npm\\claude.cmd', ['--version']); + expect(execFileMock).toHaveBeenCalled(); + expect(execMock).toHaveBeenCalled(); + expect(result.stdout).toBe('1.2.3'); + }); + }); +}); From 995b6ef8439adc23438461431432a84ad130278f Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 15:58:38 +0200 Subject: [PATCH 08/37] improvements --- .../services/infrastructure/ConfigManager.ts | 5 ++-- .../infrastructure/NotificationManager.ts | 4 ++-- .../infrastructure/SshConfigParser.ts | 6 ++--- .../infrastructure/SshConnectionManager.ts | 19 ++++++++------- src/main/services/parsing/ClaudeMdReader.ts | 5 ++-- .../services/team/ClaudeBinaryResolver.ts | 10 ++++---- src/main/services/team/FileContentResolver.ts | 10 ++++++-- .../services/team/TeamProvisioningService.ts | 3 ++- .../components/team/kanban/KanbanTaskCard.tsx | 24 +++++++++---------- test/main/utils/pathValidation.test.ts | 4 ++-- 10 files changed, 48 insertions(+), 42 deletions(-) diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index d1775f6f..64f1dca8 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -9,11 +9,10 @@ * - Handle JSON parse errors gracefully */ -import { setClaudeBasePathOverride } from '@main/utils/pathDecoder'; +import { getHomeDir, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import { validateRegexPattern } from '@main/utils/regexValidation'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager'; @@ -23,7 +22,7 @@ import type { SshConnectionProfile } from '@shared/types/api'; const logger = createLogger('Service:ConfigManager'); -const CONFIG_DIR = path.join(os.homedir(), '.claude'); +const CONFIG_DIR = path.join(getHomeDir(), '.claude'); const CONFIG_FILENAME = 'claude-devtools-config.json'; const DEFAULT_CONFIG_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME); diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index a92db953..b68683a4 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -12,11 +12,11 @@ * - Emit IPC events to renderer: notification:new, notification:updated */ +import { getHomeDir } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { type BrowserWindow, Notification } from 'electron'; import { EventEmitter } from 'events'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { type DetectedError } from '../error/ErrorMessageBuilder'; @@ -77,7 +77,7 @@ const MAX_NOTIFICATIONS = 100; const THROTTLE_MS = 5000; /** Path to notifications storage file */ -const NOTIFICATIONS_PATH = path.join(os.homedir(), '.claude', 'claude-devtools-notifications.json'); +const NOTIFICATIONS_PATH = path.join(getHomeDir(), '.claude', 'claude-devtools-notifications.json'); // ============================================================================= // NotificationManager Class diff --git a/src/main/services/infrastructure/SshConfigParser.ts b/src/main/services/infrastructure/SshConfigParser.ts index d49f80ee..a25ef7c5 100644 --- a/src/main/services/infrastructure/SshConfigParser.ts +++ b/src/main/services/infrastructure/SshConfigParser.ts @@ -8,9 +8,9 @@ * - Gracefully handle missing/unreadable files */ +import { getHomeDir } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import SSHConfig from 'ssh-config'; @@ -22,7 +22,7 @@ export class SshConfigParser { private configPath: string; constructor(configPath?: string) { - this.configPath = configPath ?? path.join(os.homedir(), '.ssh', 'config'); + this.configPath = configPath ?? path.join(getHomeDir(), '.ssh', 'config'); } /** @@ -151,7 +151,7 @@ export class SshConfigParser { } const pattern = match[1].trim(); - const expandedPattern = pattern.replace(/^~/, os.homedir()); + const expandedPattern = pattern.replace(/^~/, getHomeDir()); try { // Handle glob-like patterns by checking if the path contains wildcards diff --git a/src/main/services/infrastructure/SshConnectionManager.ts b/src/main/services/infrastructure/SshConnectionManager.ts index f8fdf48f..152bf060 100644 --- a/src/main/services/infrastructure/SshConnectionManager.ts +++ b/src/main/services/infrastructure/SshConnectionManager.ts @@ -9,6 +9,7 @@ * - Handle reconnection on errors */ +import { getHomeDir } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; import { EventEmitter } from 'events'; @@ -267,7 +268,7 @@ export class SshConnectionManager extends EventEmitter { break; case 'privateKey': { - const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa'); + const keyPath = config.privateKeyPath ?? path.join(getHomeDir(), '.ssh', 'id_rsa'); try { const keyData = await fs.promises.readFile(keyPath, 'utf8'); connectConfig.privateKey = keyData; @@ -347,15 +348,15 @@ export class SshConnectionManager extends EventEmitter { const knownPaths = [ // 1Password SSH agent path.join( - os.homedir(), + getHomeDir(), 'Library', 'Group Containers', '2BUA8C4S2C.com.1password', 'agent.sock' ), - path.join(os.homedir(), '.1password', 'agent.sock'), + path.join(getHomeDir(), '.1password', 'agent.sock'), // Common user agent socket - path.join(os.homedir(), '.ssh', 'agent.sock'), + path.join(getHomeDir(), '.ssh', 'agent.sock'), ]; // Linux: add system paths @@ -395,8 +396,8 @@ export class SshConnectionManager extends EventEmitter { // The config parser already told us there's an identity file. // Try common identity file locations from config const configKeyPaths = [ - path.join(os.homedir(), '.ssh', 'id_ed25519'), - path.join(os.homedir(), '.ssh', 'id_rsa'), + path.join(getHomeDir(), '.ssh', 'id_ed25519'), + path.join(getHomeDir(), '.ssh', 'id_rsa'), ]; for (const keyPath of configKeyPaths) { try { @@ -417,9 +418,9 @@ export class SshConnectionManager extends EventEmitter { // Try default key files const defaultKeys = [ - path.join(os.homedir(), '.ssh', 'id_ed25519'), - path.join(os.homedir(), '.ssh', 'id_rsa'), - path.join(os.homedir(), '.ssh', 'id_ecdsa'), + path.join(getHomeDir(), '.ssh', 'id_ed25519'), + path.join(getHomeDir(), '.ssh', 'id_rsa'), + path.join(getHomeDir(), '.ssh', 'id_ecdsa'), ]; for (const keyPath of defaultKeys) { diff --git a/src/main/services/parsing/ClaudeMdReader.ts b/src/main/services/parsing/ClaudeMdReader.ts index e78447c7..14430a3a 100644 --- a/src/main/services/parsing/ClaudeMdReader.ts +++ b/src/main/services/parsing/ClaudeMdReader.ts @@ -8,10 +8,9 @@ * - Support tilde (~) expansion to home directory */ -import { encodePath, getClaudeBasePath } from '@main/utils/pathDecoder'; +import { encodePath, getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder'; import { countTokens } from '@main/utils/tokenizer'; import { createLogger } from '@shared/utils/logger'; -import { app } from 'electron'; import * as path from 'path'; import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; @@ -48,7 +47,7 @@ export interface ClaudeMdReadResult { */ function expandTilde(filePath: string): string { if (filePath.startsWith('~')) { - const homeDir = app.getPath('home'); + const homeDir = getHomeDir(); return path.join(homeDir, filePath.slice(1)); } return filePath; diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index a897975c..f1a39934 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -1,5 +1,5 @@ +import { getHomeDir } from '@main/utils/pathDecoder'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; async function isExecutable(filePath: string): Promise { @@ -61,7 +61,7 @@ function expandWindowsBinaryNames(binaryName: string): string[] { } async function collectNvmCandidates(): Promise { - const nvmNodeRoot = path.join(os.homedir(), '.nvm', 'versions', 'node'); + const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node'); let versions: string[]; try { versions = await fs.promises.readdir(nvmNodeRoot); @@ -172,9 +172,9 @@ export class ClaudeBinaryResolver { const candidateDirs: string[] = [ // Native binary installation path (claude install) - path.join(os.homedir(), '.local', 'bin'), - path.join(os.homedir(), '.npm-global', 'bin'), - path.join(os.homedir(), '.npm', 'bin'), + path.join(getHomeDir(), '.local', 'bin'), + path.join(getHomeDir(), '.npm-global', 'bin'), + path.join(getHomeDir(), '.npm', 'bin'), process.platform === 'win32' ? process.env.APPDATA ? path.join(process.env.APPDATA, 'npm') diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 4b1f86fb..0cfb6dfa 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -1,3 +1,4 @@ +import { getHomeDir } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { diffLines } from 'diff'; import { createReadStream } from 'fs'; @@ -257,8 +258,13 @@ export class FileContentResolver { if (!backupFileName) continue; // Construct the file-history path - const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - const historyPath = path.join(homeDir, '.claude', 'file-history', sessionId, backupFileName); + const historyPath = path.join( + getHomeDir(), + '.claude', + 'file-history', + sessionId, + backupFileName + ); try { await access(historyPath); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8c1922ba..d84a0def 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5,6 +5,7 @@ import { extractBaseDir, getAutoDetectedClaudeBasePath, getClaudeBasePath, + getHomeDir, getProjectsBasePath, getTasksBasePath, getTeamsBasePath, @@ -2439,7 +2440,7 @@ export class TeamProvisioningService { private async buildProvisioningEnv(): Promise { const shellEnv = await resolveInteractiveShellEnv(); - const home = shellEnv.HOME?.trim() || process.env.HOME?.trim() || os.homedir(); + const home = shellEnv.HOME?.trim() || process.env.HOME?.trim() || getHomeDir(); const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username; const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh'; const xdgConfigHome = diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 5b6ecc68..b9d3345d 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -228,24 +228,24 @@ export const KanbanTaskCard = ({ #{task.id} {task.owner ? : null} - {task.needsClarification ? ( - - - {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} - - ) : null} {!compact && (
{task.subject}
)}
+ {task.needsClarification ? ( + + + {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} + + ) : null} {compact && (
{task.subject} diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts index 430e1e6e..1423b086 100644 --- a/test/main/utils/pathValidation.test.ts +++ b/test/main/utils/pathValidation.test.ts @@ -7,7 +7,7 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder'; +import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder'; import { isPathWithinAllowedDirectories, @@ -17,7 +17,7 @@ import { } from '../../../src/main/utils/pathValidation'; describe('pathValidation', () => { - const homeDir = os.homedir(); + const homeDir = getHomeDir(); const claudeDir = path.join(homeDir, '.claude'); const testProjectPath = path.resolve('/home/user/my-project'); From d948cc6fb4a6e9b1523ad3390bc8a05e34137fe8 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 19:38:22 +0200 Subject: [PATCH 09/37] fix: enhance child process environment handling for Windows - Added a helper function to build the child process environment with the correct HOME directory, addressing issues with non-ASCII usernames on Windows. - Updated CLI installer methods to utilize the new environment setup for improved compatibility and error handling. --- .../infrastructure/CliInstallerService.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 9af488fa..cdb53b75 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -17,6 +17,7 @@ * - Human-readable error messages per phase */ +import { getHomeDir } from '@main/utils/pathDecoder'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { execFile, spawn } from 'child_process'; @@ -64,6 +65,20 @@ const EBUSY_MAX_RETRIES = 3; /** Delay between EBUSY retries (multiplied by attempt number) */ const EBUSY_RETRY_DELAY_MS = 2000; +/** + * Build env for child processes with correct HOME. + * On Windows with non-ASCII usernames, process.env may have a broken HOME/USERPROFILE. + * getHomeDir() uses Electron's app.getPath('home') which handles Unicode correctly. + */ +function buildChildEnv(): NodeJS.ProcessEnv { + const home = getHomeDir(); + return { + ...process.env, + HOME: home, + USERPROFILE: home, + }; +} + // ============================================================================= // Helpers // ============================================================================= @@ -209,6 +224,7 @@ export class CliInstallerService { try { const { stdout } = await execFileAsync(binaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, + env: buildChildEnv(), }); result.installedVersion = normalizeVersion(stdout); logger.info( @@ -222,6 +238,7 @@ export class CliInstallerService { try { const { stdout: authStdout } = await execFileAsync(binaryPath, ['auth', 'status'], { timeout: VERSION_TIMEOUT_MS, + env: buildChildEnv(), }); const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string }; result.authLoggedIn = auth.loggedIn === true; @@ -450,7 +467,7 @@ export class CliInstallerService { private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise { return new Promise((resolve, reject) => { const child = spawn(binaryPath, ['install'], { - env: { ...process.env, CLAUDE_SKIP_ANALYTICS: '1' }, + env: { ...buildChildEnv(), CLAUDE_SKIP_ANALYTICS: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); From 69adf3488e62157f7183923dc4c15d24b1c21330 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 19:59:26 +0200 Subject: [PATCH 10/37] refactor: replace execFile and spawn with execCli and spawnCli in CLI and TeamProvisioning services - Updated CliInstallerService and TeamProvisioningService to use execCli and spawnCli for improved error handling and compatibility, particularly on Windows. - Enhanced child process utility functions to better manage non-ASCII paths and provide consistent behavior across different platforms. - Adjusted tests to mock new child process utilities and verify correct usage in service methods. --- .../infrastructure/CliInstallerService.ts | 13 +-- .../services/team/TeamProvisioningService.ts | 7 +- src/main/utils/childProcess.ts | 78 +++++++++++--- .../CliInstallerService.test.ts | 30 +++--- test/main/utils/childProcess.test.ts | 101 +++++++++++++----- 5 files changed, 164 insertions(+), 65 deletions(-) diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index cdb53b75..f8f62b84 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -17,17 +17,16 @@ * - Human-readable error messages per phase */ +import { execCli, spawnCli } from '@main/utils/childProcess'; import { getHomeDir } from '@main/utils/pathDecoder'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; -import { execFile, spawn } from 'child_process'; import { createHash } from 'crypto'; import { createWriteStream, existsSync, promises as fsp } from 'fs'; import http from 'http'; import https from 'https'; import { tmpdir } from 'os'; import { join } from 'path'; -import { promisify } from 'util'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; @@ -37,10 +36,6 @@ import type { IncomingMessage } from 'http'; const logger = createLogger('CliInstallerService'); -// Note: execFile (not exec) is used intentionally — no shell injection risk. -// Arguments are passed as arrays, never interpolated into shell strings. -const execFileAsync = promisify(execFile); - // ============================================================================= // Constants // ============================================================================= @@ -222,7 +217,7 @@ export class CliInstallerService { result.binaryPath = binaryPath; try { - const { stdout } = await execFileAsync(binaryPath, ['--version'], { + const { stdout } = await execCli(binaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, env: buildChildEnv(), }); @@ -236,7 +231,7 @@ export class CliInstallerService { // Check auth status try { - const { stdout: authStdout } = await execFileAsync(binaryPath, ['auth', 'status'], { + const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], { timeout: VERSION_TIMEOUT_MS, env: buildChildEnv(), }); @@ -466,7 +461,7 @@ export class CliInstallerService { */ private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise { return new Promise((resolve, reject) => { - const child = spawn(binaryPath, ['install'], { + const child = spawnCli(binaryPath, ['install'], { env: { ...buildChildEnv(), CLAUDE_SKIP_ANALYTICS: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d84a0def..53845dd7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; +import { spawnCli } from '@main/utils/childProcess'; import { encodePath, extractBaseDir, @@ -930,7 +931,7 @@ export class TeamProvisioningService { ); } try { - child = spawn( + child = spawnCli( claudePath, [ '--input-format', @@ -1252,7 +1253,7 @@ export class TeamProvisioningService { // --resume is for existing sessions and may show an interactive picker if not found. try { - child = spawn(claudePath, launchArgs, { + child = spawnCli(claudePath, launchArgs, { cwd: request.cwd, env: { ...shellEnv, @@ -3127,7 +3128,7 @@ export class TeamProvisioningService { timeoutMs: number ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { - const child = spawn(claudePath, args, { + const child = spawnCli(claudePath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 18474b16..c40678f0 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -1,15 +1,59 @@ -import { spawn, execFile, exec, SpawnOptions, ExecFileOptions } from 'child_process'; -import { promisify } from 'util'; +import { + exec, + execFile, + type ExecFileOptions, + type ExecOptions, + spawn, + type SpawnOptions, +} from 'child_process'; -// re-exported helpers used throughout the codebase -export const execFileAsync = promisify(execFile); -export const execAsync = promisify(exec); +/** + * Promise wrapper for execFile that always returns { stdout, stderr }. + * Unlike promisify(execFile), this works correctly with mocked execFile + * (promisify relies on a custom symbol that mocks don't have). + */ +function execFileAsync( + cmd: string, + args: string[], + options: ExecFileOptions = {} +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(cmd, args, options, (err, stdout, stderr) => { + if (err) + reject( + err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error') + ); + else resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); +} + +/** + * Promise wrapper for exec. Used exclusively as a Windows shell fallback + * when execFile fails with EINVAL on non-ASCII binary paths. The command + * string is built from a known binary path + args, NOT from user input. + */ +function execShellAsync( + cmd: string, + options: ExecOptions = {} +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + // eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) + exec(cmd, options, (err, stdout, stderr) => { + if (err) + reject( + err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error') + ); + else resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); +} /** * Returns true if the string contains any non-ASCII character. */ function containsNonAscii(str: string): boolean { - return /[^\x00-\x7F]/.test(str); + return [...str].some((c) => c.charCodeAt(0) > 127); } /** @@ -30,7 +74,7 @@ function needsShell(binaryPath: string): boolean { * the shell itself will handle most quoting rules. */ function quoteArg(arg: string): string { - if (/[^A-Za-z0-9_\-\/.]/.test(arg)) { + if (/[^A-Za-z0-9_\-/.]/.test(arg)) { return `"${arg.replace(/"/g, '\\"')}"`; } return arg; @@ -56,10 +100,14 @@ export async function execCli( try { const result = await execFileAsync(target, args, options); return { stdout: String(result.stdout), stderr: String(result.stderr) }; - } catch (err: any) { + } catch (err: unknown) { // fall through to shell fallback only when the error matches the // Windows "invalid argument" problem; otherwise rethrow. - if (!(err && err.code === 'EINVAL')) { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as { code?: string }).code + : undefined; + if (code !== 'EINVAL') { throw err; } } @@ -67,7 +115,7 @@ export async function execCli( // shell fallback (Windows only; others shouldn't reach here) const cmd = [target, ...args].map(quoteArg).join(' '); - const shellResult = await execAsync(cmd, options as unknown as import('child_process').ExecOptions); + const shellResult = await execShellAsync(cmd, options as unknown as ExecOptions); return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) }; } @@ -81,17 +129,21 @@ export function spawnCli( binaryPath: string, args: string[], options: SpawnOptions = {} -) { +): ReturnType { if (process.platform === 'win32' && needsShell(binaryPath)) { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); + // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) return spawn(cmd, { shell: true, ...options }); } try { return spawn(binaryPath, args, options); - } catch (err: any) { - if (process.platform === 'win32' && err && err.code === 'EINVAL') { + } catch (err: unknown) { + const code = + err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined; + if (process.platform === 'win32' && code === 'EINVAL') { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); + // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) return spawn(cmd, { shell: true, ...options }); } throw err; diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 099a082c..0cf134f6 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies before importing service -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@main/utils/childProcess', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - execFile: vi.fn(), + execCli: vi.fn().mockRejectedValue(new Error('execCli not configured')), }; }); @@ -63,6 +63,7 @@ import { normalizeVersion, } from '@main/services/infrastructure/CliInstallerService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { execCli } from '@main/utils/childProcess'; /** * Helper: allow expected console.error/warn calls in tests where service logs errors. @@ -111,23 +112,20 @@ describe('CliInstallerService', () => { const fakePath = 'C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd'; vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(fakePath); - // mock execFile to throw EINVAL first - const err: any = new Error('spawn EINVAL'); - err.code = 'EINVAL'; - const childProcess = await import('child_process'); - vi.spyOn(childProcess, 'execFile').mockImplementation((cmd, args, opts, cb) => { - cb(err, '', ''); - return {} as any; - }); - // mock exec to succeed as fallback - vi.spyOn(childProcess, 'exec').mockImplementation((cmd, opts, cb) => { - cb(null, '2.3.4', ''); - return {} as any; - }); + // execCli handles the EINVAL → shell fallback internally; + // here we just verify the service delegates to execCli correctly. + vi.mocked(execCli) + .mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' }) // --version + .mockResolvedValueOnce({ stdout: '{}', stderr: '' }); // auth status const status = await service.getStatus(); expect(status.installed).toBe(true); expect(status.installedVersion).toBe('2.3.4'); + expect(execCli).toHaveBeenCalledWith( + fakePath, + ['--version'], + expect.objectContaining({ timeout: expect.any(Number) }) + ); }); }); diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index 2ead7909..1c705c61 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -1,12 +1,17 @@ +// @vitest-environment node import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Mock the entire child_process module so that we can inspect how our helpers // invoke spawn/exec without hitting the real filesystem or spawning anything. -vi.mock('child_process', () => ({ - spawn: vi.fn(), - execFile: vi.fn(), - exec: vi.fn(), -})); +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + execFile: vi.fn(), + exec: vi.fn(), + }; +}); // Import after the mock call so that the mocked module is returned. import * as child from 'child_process'; @@ -16,6 +21,8 @@ import { spawnCli, execCli } from '@main/utils/childProcess'; function setPlatform(value: string) { Object.defineProperty(process, 'platform', { value, + configurable: true, + writable: true, }); } @@ -52,13 +59,31 @@ describe('cli child process helpers', () => { }); spawnMock.mockImplementationOnce(() => fake); - const result = spawnCli('C:\\Users\\\\AppData\\Roaming\\npm\\claude.cmd', ['a', 'b'], { + // Use ASCII path so needsShell returns false and we go through the try/catch EINVAL path + const result = spawnCli('C:\\bin\\claude.exe', ['a', 'b'], { env: { FOO: 'bar' }, }); expect(spawnMock).toHaveBeenCalledTimes(2); const secondArg0 = spawnMock.mock.calls[1][0] as string; - expect(secondArg0).toMatch(/claude\.cmd/); - expect(spawnMock.mock.calls[1][2]).toMatchObject({ shell: true, env: { FOO: 'bar' } }); + expect(secondArg0).toMatch(/claude\.exe/); + expect(spawnMock.mock.calls[1][1]).toMatchObject({ shell: true, env: { FOO: 'bar' } }); + expect(result).toBe(fake); + }); + + it('uses shell directly when path contains non-ASCII on windows', () => { + setPlatform('win32'); + const fake = {} as any; + const spawnMock = child.spawn as unknown as vi.Mock; + spawnMock.mockReturnValue(fake); + + const result = spawnCli('C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd', ['a', 'b'], { + env: { FOO: 'bar' }, + }); + // Non-ASCII detected upfront — single spawn call with shell: true + expect(spawnMock).toHaveBeenCalledTimes(1); + const shellCmd = spawnMock.mock.calls[0][0] as string; + expect(shellCmd).toMatch(/claude\.cmd/); + expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, env: { FOO: 'bar' } }); expect(result).toBe(fake); }); @@ -72,37 +97,65 @@ describe('cli child process helpers', () => { }); describe('execCli', () => { - it('invokes execFile when path is normal', async () => { + it('invokes execFile when path is ASCII on windows', async () => { setPlatform('win32'); const execFileMock = child.execFile as unknown as vi.Mock; - execFileMock.mockImplementation((cmd, args, opts, cb) => { - cb(null, 'ok', ''); - return {} as any; - }); + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + cb(null, 'ok', ''); + return {} as any; + } + ); const result = await execCli('C:\\bin\\claude.exe', ['--version']); - expect(execFileMock).toHaveBeenCalledWith('C:\\bin\\claude.exe', ['--version'], {}, expect.any(Function)); + expect(execFileMock).toHaveBeenCalledWith( + 'C:\\bin\\claude.exe', + ['--version'], + {}, + expect.any(Function) + ); expect(result.stdout).toBe('ok'); }); - it('falls back to exec shell when execFile throws EINVAL or path contains non-ascii', async () => { + it('skips straight to shell when path contains non-ASCII on windows', async () => { setPlatform('win32'); const execFileMock = child.execFile as unknown as vi.Mock; - execFileMock.mockImplementation((cmd, args, opts, cb) => { - const err: any = new Error('spawn EINVAL'); - err.code = 'EINVAL'; - cb(err, '', ''); - return {} as any; - }); const execMock = child.exec as unknown as vi.Mock; - execMock.mockImplementation((cmd, opts, cb) => { + execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => { cb(null, '1.2.3', ''); return {} as any; }); - const result = await execCli('C:\\Users\\\\AppData\\Roaming\\npm\\claude.cmd', ['--version']); - expect(execFileMock).toHaveBeenCalled(); + const result = await execCli('C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd', [ + '--version', + ]); + // non-ASCII path detected upfront — execFile should NOT be called + expect(execFileMock).not.toHaveBeenCalled(); expect(execMock).toHaveBeenCalled(); expect(result.stdout).toBe('1.2.3'); }); + + it('falls back to shell when execFile throws EINVAL on windows', async () => { + setPlatform('win32'); + const execFileMock = child.execFile as unknown as vi.Mock; + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + const err: any = new Error('spawn EINVAL'); + err.code = 'EINVAL'; + cb(err, '', ''); + return {} as any; + } + ); + const execMock = child.exec as unknown as vi.Mock; + execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => { + cb(null, '2.3.4', ''); + return {} as any; + }); + + // ASCII path — goes through execFile first, gets EINVAL, falls back to shell + const result = await execCli('C:\\bin\\claude.exe', ['--version']); + expect(execFileMock).toHaveBeenCalled(); + expect(execMock).toHaveBeenCalled(); + expect(result.stdout).toBe('2.3.4'); + }); }); }); From 228e8868ed0f6978bc228481b97331d00b9fbf5b Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 20:06:03 +0200 Subject: [PATCH 11/37] fix --- src/main/utils/childProcess.ts | 21 ++++++++++++++------- test/main/utils/childProcess.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index c40678f0..a1f49e85 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -68,14 +68,21 @@ function needsShell(binaryPath: string): boolean { } /** - * Minimal quoting for command‑line arguments when building a shell - * invocation. We only escape spaces and double quotes since our - * callers only ever use simple strings (paths, flags, literals) and - * the shell itself will handle most quoting rules. + * Quote an argument for cmd.exe shell invocation on Windows. + * + * cmd.exe rules: + * - Double-quote args containing spaces or special characters + * - Inside double quotes, escape literal `"` as `""` + * - `%` is expanded as env var even inside double quotes — escape as `%%` + * - `^`, `&`, `|`, `<`, `>` are safe inside double quotes + * + * Our callers only pass controlled strings (binary paths, CLI flags), + * NOT arbitrary user input. */ function quoteArg(arg: string): string { if (/[^A-Za-z0-9_\-/.]/.test(arg)) { - return `"${arg.replace(/"/g, '\\"')}"`; + const escaped = arg.replace(/%/g, '%%').replace(/"/g, '""'); + return `"${escaped}"`; } return arg; } @@ -133,7 +140,7 @@ export function spawnCli( if (process.platform === 'win32' && needsShell(binaryPath)) { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { shell: true, ...options }); + return spawn(cmd, { ...options, shell: true }); } try { @@ -144,7 +151,7 @@ export function spawnCli( if (process.platform === 'win32' && code === 'EINVAL') { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { shell: true, ...options }); + return spawn(cmd, { ...options, shell: true }); } throw err; } diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index 1c705c61..cc03192f 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -134,6 +134,34 @@ describe('cli child process helpers', () => { expect(result.stdout).toBe('1.2.3'); }); + it('escapes percent signs and quotes for cmd.exe in shell fallback', async () => { + setPlatform('win32'); + const execMock = child.exec as unknown as vi.Mock; + execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => { + cb(null, 'ok', ''); + return {} as any; + }); + + await execCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--model', 'test%PATH%"arg']); + const shellCmd = execMock.mock.calls[0][0] as string; + // %PATH% must become %%PATH%% to prevent cmd.exe env var expansion + expect(shellCmd).toContain('%%PATH%%'); + // double quote inside arg must become "" (cmd.exe escaping) + expect(shellCmd).toContain('""arg'); + // should NOT contain \" (Unix-style escaping) + expect(shellCmd).not.toContain('\\"'); + }); + + it('shell: true cannot be overridden by caller options', () => { + setPlatform('win32'); + const spawnMock = child.spawn as unknown as vi.Mock; + spawnMock.mockReturnValue({} as any); + + spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--version'], { shell: false } as any); + // shell: true must win over caller's shell: false + expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true }); + }); + it('falls back to shell when execFile throws EINVAL on windows', async () => { setPlatform('win32'); const execFileMock = child.execFile as unknown as vi.Mock; From 697f5bb8964e1c6c601c003ae81b4ef1581fa878 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 20:10:54 +0200 Subject: [PATCH 12/37] fix windows --- .../infrastructure/CliInstallerService.ts | 4 +- .../services/team/TeamProvisioningService.ts | 16 ++++---- src/main/utils/childProcess.ts | 40 +++++++++++++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index f8f62b84..61948121 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -17,7 +17,7 @@ * - Human-readable error messages per phase */ -import { execCli, spawnCli } from '@main/utils/childProcess'; +import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; import { getHomeDir } from '@main/utils/pathDecoder'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; @@ -467,7 +467,7 @@ export class CliInstallerService { }); const timeout = setTimeout(() => { - child.kill(); + killProcessTree(child); reject( new Error( `Timed out after ${INSTALL_TIMEOUT_MS / 1000}s. ` + diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 53845dd7..402f5d08 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; -import { spawnCli } from '@main/utils/childProcess'; +import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { encodePath, extractBaseDir, @@ -1035,7 +1035,7 @@ export class TeamProvisioningService { void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); if (readyOnTimeout) { return; // cleanupRun already called inside tryCompleteAfterTimeout } @@ -1344,7 +1344,7 @@ export class TeamProvisioningService { void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); if (readyOnTimeout) { return; } @@ -1395,7 +1395,7 @@ export class TeamProvisioningService { run.cancelRequested = true; run.processKilled = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); run.onProgress(progress); this.cleanupRun(run); @@ -1824,7 +1824,7 @@ export class TeamProvisioningService { run.processKilled = true; run.cancelRequested = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); this.cleanupRun(run); @@ -1966,7 +1966,7 @@ export class TeamProvisioningService { // Kill the process on provisioning error run.processKilled = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); this.cleanupRun(run); } else if (run.provisioningComplete) { // Post-provisioning error: process alive, waiting for input @@ -2028,7 +2028,7 @@ export class TeamProvisioningService { run.onProgress(progress); run.processKilled = true; run.child?.stdin?.end(); - run.child?.kill(); + killProcessTree(run.child); this.cleanupRun(run); return; } @@ -3137,7 +3137,7 @@ export class TeamProvisioningService { const stderrChunks: Buffer[] = []; const timeoutHandle = setTimeout(() => { - child.kill(); + killProcessTree(child); reject(new Error(`Timeout running: claude ${args.join(' ')}`)); }, timeoutMs); diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index a1f49e85..7a420ab2 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -1,4 +1,5 @@ import { + type ChildProcess, exec, execFile, type ExecFileOptions, @@ -6,6 +7,7 @@ import { spawn, type SpawnOptions, } from 'child_process'; +import path from 'path'; /** * Promise wrapper for execFile that always returns { stdout, stderr }. @@ -156,3 +158,41 @@ export function spawnCli( throw err; } } + +/** + * Kill a child process and its entire process tree. + * + * On Windows with `shell: true`, `child.kill()` only kills the intermediate + * `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned. + * `taskkill /T /F /PID` recursively kills the entire process tree. + * + * On macOS/Linux, processes are killed directly (no shell wrapper), so + * the standard `child.kill(signal)` works correctly. + */ +export function killProcessTree( + child: ChildProcess | null | undefined, + signal?: NodeJS.Signals +): void { + if (!child?.pid) { + // Process is null, never started, or already exited + return; + } + + if (process.platform === 'win32') { + try { + const taskkillPath = path.join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'taskkill.exe' + ); + execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], () => { + // Best-effort — ignore errors (process may have already exited) + }); + return; + } catch { + // taskkill failed, fall through to standard kill + } + } + + child.kill(signal); +} From 17376623549c6848aca63f9976344b83f1c40b14 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 20:55:38 +0200 Subject: [PATCH 13/37] feat: add download badges with direct links per platform --- README.md | 40 ++++- src/main/index.ts | 7 + .../infrastructure/PtyTerminalService.ts | 10 +- .../services/team/ClaudeBinaryResolver.ts | 4 +- .../services/team/TeamProvisioningService.ts | 43 ++++- .../components/common/UpdateDialog.tsx | 35 +--- .../components/dashboard/CliStatusBanner.tsx | 3 + .../components/terminal/TerminalModal.tsx | 155 ++++++++++++++---- 8 files changed, 217 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 42c3464f..fae15422 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,39 @@
-

+ + + + + + +
- Download for macOS -    - - Download for Linux -    - - Download for Windows + macOS Apple Silicon -

+
+ + macOS Intel + +
+ + Windows + + + + Linux AppImage + +
+ + .deb +   + + .rpm +   + + .pacman + +

100% free, open source. No API keys. No configuration. diff --git a/src/main/index.ts b/src/main/index.ts index 4547d15c..1486cb1a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -572,6 +572,13 @@ function shutdownServices(): void { sshConnectionManager.dispose(); } + // Stop all running team provisioning processes + if (teamProvisioningService) { + for (const teamName of teamProvisioningService.getAliveTeams()) { + teamProvisioningService.stopTeam(teamName); + } + } + // Kill all PTY processes if (ptyTerminalService) { ptyTerminalService.killAll(); diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index 55f02753..7e50e3de 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -58,12 +58,18 @@ export class PtyTerminalService { ? (process.env.COMSPEC ?? 'powershell.exe') : (process.env.SHELL ?? '/bin/bash')); + const home = getHomeDir(); const pty = nodePty.spawn(shell, options?.args ?? [], { name: 'xterm-256color', cols: options?.cols ?? 80, rows: options?.rows ?? 24, - cwd: options?.cwd ?? getHomeDir(), - env: { ...process.env, ...options?.env } as Record, + cwd: options?.cwd ?? home, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + ...options?.env, + } as Record, }); pty.onData((data) => this.send(TERMINAL_DATA, id, data)); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index f1a39934..90e345fd 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -176,9 +176,7 @@ export class ClaudeBinaryResolver { path.join(getHomeDir(), '.npm-global', 'bin'), path.join(getHomeDir(), '.npm', 'bin'), process.platform === 'win32' - ? process.env.APPDATA - ? path.join(process.env.APPDATA, 'npm') - : '' + ? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm') : '/usr/local/bin', process.platform === 'win32' ? '' : '/opt/homebrew/bin', ].filter((candidate) => candidate.length > 0); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 402f5d08..932a5f07 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -196,10 +196,22 @@ async function readShellEnv(shellPath: string, args: string[]): Promise { timeoutHandle = null; child.kill(); - reject(new Error('shell env resolve timeout')); + // SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc) + setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + /* already dead */ + } + }, 3000); + if (!settled) { + settled = true; + reject(new Error('shell env resolve timeout')); + } }, SHELL_ENV_TIMEOUT_MS); child.stdout?.on('data', (chunk: Buffer) => { @@ -210,13 +222,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise { if (timeoutHandle) { clearTimeout(timeoutHandle); } - resolve(Buffer.concat(chunks).toString('utf8')); + if (!settled) { + settled = true; + resolve(Buffer.concat(chunks).toString('utf8')); + } }); }); return parseNullSeparatedEnv(envDump); @@ -965,7 +983,7 @@ export class TeamProvisioningService { run.child = child; // Send provisioning prompt as first stream-json message (SDKUserMessage format) - if (child.stdin) { + if (child.stdin?.writable) { const message = JSON.stringify({ type: 'user', message: { @@ -1275,7 +1293,7 @@ export class TeamProvisioningService { run.child = child; // Send launch prompt - if (child.stdin) { + if (child.stdin?.writable) { const message = JSON.stringify({ type: 'user', message: { @@ -1982,7 +2000,9 @@ export class TeamProvisioningService { * Process stays alive for subsequent tasks. */ private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { - if (run.cancelRequested) return; + // Guard: must be set synchronously BEFORE any await to prevent + // double-invocation from filesystem monitor + stream-json racing. + if (run.provisioningComplete || run.cancelRequested) return; run.provisioningComplete = true; this.setLeadActivity(run, 'idle'); @@ -2058,6 +2078,11 @@ export class TeamProvisioningService { run.timeoutHandle = null; } this.stopFilesystemMonitor(run); + // Remove stream listeners to prevent data handlers firing on a cleaned-up run + if (run.child) { + run.child.stdout?.removeAllListeners('data'); + run.child.stderr?.removeAllListeners('data'); + } this.activeByTeam.delete(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); @@ -2441,7 +2466,10 @@ export class TeamProvisioningService { private async buildProvisioningEnv(): Promise { const shellEnv = await resolveInteractiveShellEnv(); - const home = shellEnv.HOME?.trim() || process.env.HOME?.trim() || getHomeDir(); + // getHomeDir() uses Electron's app.getPath('home') which handles Unicode + // correctly on Windows. Prefer it over process.env which may be garbled. + const electronHome = getHomeDir(); + const home = shellEnv.HOME?.trim() || electronHome; const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username; const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh'; const xdgConfigHome = @@ -2455,6 +2483,7 @@ export class TeamProvisioningService { ...process.env, ...shellEnv, HOME: home, + USERPROFILE: home, USER: user, LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, SHELL: shell, diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index 3f8312ab..8cad8199 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -2,7 +2,7 @@ * UpdateDialog - Modal dialog shown when a new version is available. * * Prompts the user to download the update or dismiss it. - * Release notes may be HTML from the updater; we normalize to text and render as markdown. + * Release notes (markdown from GitHub) are rendered with ReactMarkdown. */ import { useEffect, useRef } from 'react'; @@ -14,31 +14,6 @@ import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { X } from 'lucide-react'; import remarkGfm from 'remark-gfm'; -/** - * Normalize release notes: strip HTML tags and convert block elements to newlines. - * Uses DOMParser for proper HTML entity decoding (handles all entities like —, ', etc.) - */ -function normalizeReleaseNotes(html: string): string { - if (!html?.trim()) return ''; - - // Convert block elements to newlines for better formatting - const processed = html - .replace(/<\/p>\s*/gi, '\n\n') - .replace(/\s*/gi, '\n') - .replace(/<\/div>\s*/gi, '\n') - .replace(/<\/li>\s*/gi, '\n') - .replace(/<\/h[1-6]>\s*/gi, '\n\n'); - - // Use DOMParser to decode HTML entities and strip remaining tags - // This properly handles all HTML entities ( , —, ', etc.) - const parser = new DOMParser(); - const doc = parser.parseFromString(processed, 'text/html'); - const text = doc.body.textContent || ''; - - // Normalize multiple newlines - return text.replace(/\n{3,}/g, '\n\n').trim(); -} - export const UpdateDialog = (): React.JSX.Element | null => { const showUpdateDialog = useStore((s) => s.showUpdateDialog); const availableVersion = useStore((s) => s.availableVersion); @@ -141,14 +116,14 @@ export const UpdateDialog = (): React.JSX.Element | null => { )}

- {/* Release notes — normalize HTML then render as markdown */} + {/* Release notes */} {releaseNotes && (
{ rehypePlugins={REHYPE_PLUGINS} components={markdownComponents} > - {normalizeReleaseNotes(releaseNotes)} + {releaseNotes}
)} diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index d9aef8e4..7a51ee52 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -405,6 +405,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { onExit={() => { void fetchCliStatus(); }} + autoCloseOnSuccessMs={4000} + successMessage="Login complete" + failureMessage="Login failed" /> )} diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx index 7c7d033d..a7c7f4e7 100644 --- a/src/renderer/components/terminal/TerminalModal.tsx +++ b/src/renderer/components/terminal/TerminalModal.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import { Terminal, X } from 'lucide-react'; +import { CheckCircle, Terminal, X, XCircle } from 'lucide-react'; import { EmbeddedTerminal } from './EmbeddedTerminal'; @@ -18,6 +18,12 @@ interface TerminalModalProps { onClose: () => void; /** Called when the PTY process exits */ onExit?: (exitCode: number) => void; + /** Auto-close the modal after this many ms on success (exit code 0). 0 = disabled. */ + autoCloseOnSuccessMs?: number; + /** Custom message shown on exit code 0. Default: "Completed successfully" */ + successMessage?: string; + /** Custom message prefix for non-zero exit. Default: "Process failed" */ + failureMessage?: string; } export function TerminalModal({ @@ -27,28 +33,72 @@ export function TerminalModal({ cwd, onClose, onExit, + autoCloseOnSuccessMs = 0, + successMessage = 'Completed successfully', + failureMessage = 'Process failed', }: TerminalModalProps): React.JSX.Element { const [exited, setExited] = useState(null); + const [countdown, setCountdown] = useState(0); + const dialogRef = useRef(null); - const handleExit = (exitCode: number): void => { - setExited(exitCode); - onExit?.(exitCode); - }; + const handleExit = useCallback( + (exitCode: number): void => { + setExited(exitCode); + onExit?.(exitCode); + if (exitCode === 0 && autoCloseOnSuccessMs > 0) { + setCountdown(Math.ceil(autoCloseOnSuccessMs / 1000)); + } + }, + [onExit, autoCloseOnSuccessMs] + ); - const handleKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Escape') { - e.stopPropagation(); - onClose(); - } - }; + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }, + [onClose] + ); + + // Focus trap — focus dialog on mount + useEffect(() => { + dialogRef.current?.focus(); + }, []); + + // Countdown timer for auto-close + useEffect(() => { + if (countdown <= 0) return; + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + onClose(); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [countdown, onClose]); + + const totalSeconds = autoCloseOnSuccessMs > 0 ? Math.ceil(autoCloseOnSuccessMs / 1000) : 0; + const progressPercent = totalSeconds > 0 ? (countdown / totalSeconds) * 100 : 0; return ReactDOM.createPortal( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop handles Escape
-
+
{/* Header */}
@@ -57,28 +107,75 @@ export function TerminalModal({
- {/* Terminal area */} + {/* Terminal area — always visible, status bar overlaid at bottom */}
- {exited === null ? ( - - ) : ( -
-

- Process exited with code{' '} - {exited} -

- + + + {exited !== null && ( +
+
+ {exited === 0 ? ( +
+
+ ) : ( +
+
+ )} + +
+ + {/* Progress bar for auto-close countdown */} + {countdown > 0 && ( +
+
+
+ )}
)}
From 2f56cf4cf2777a9ea2860255c799069b9cb33715 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 21:00:39 +0200 Subject: [PATCH 14/37] refactor: move download buttons to Installation section --- README.md | 83 ++++++++++++++++++++++++------------------------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index fae15422..e14c61ab 100644 --- a/README.md +++ b/README.md @@ -13,42 +13,6 @@ CI Status

-
- - - - - - - -
- - macOS Apple Silicon - -
- - macOS Intel - -
- - Windows - - - - Linux AppImage - -
- - .deb -   - - .rpm -   - - .pacman - -
-

100% free, open source. No API keys. No configuration.

@@ -67,18 +31,43 @@ ## Installation -### Requirements +No prerequisites — Claude Code can be installed and configured directly from the app. -None. Claude Code is **not required** to be installed beforehand — the app includes a built-in installer and login, so you can set up everything directly from the app. - -### Direct Download - -| Platform | Download | Notes | -|----------|----------|-------| -| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg) | Drag to Applications. On first launch: right-click → Open | -| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg) | Drag to Applications. On first launch: right-click → Open | -| **Linux** | [`.AppImage`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage) / [`.deb`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb) / [`.rpm`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm) / [`.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.pacman) | Choose the package format for your distro | -| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" | + + + + + + +
+ + macOS Apple Silicon + +
+ + macOS Intel + +
+ + Windows + +
+ May trigger SmartScreen — click "More info" → "Run anyway" +
+ + Linux AppImage + +
+ + .deb +   + + .rpm +   + + .pacman + +
--- From 5b08cca69b2681de24415e6248113386b7c26df7 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 21:04:11 +0200 Subject: [PATCH 15/37] refactor: move Docker files to docker/ and CHANGELOG to docs/ --- .github/SECURITY.md | 4 +- .dockerignore => docker/.dockerignore | 0 Dockerfile => docker/Dockerfile | 0 .../docker-compose.yml | 6 +- CHANGELOG.md => docs/CHANGELOG.md | 0 .../services/team/TeamAgentToolsInstaller.ts | 1 + src/main/services/team/TeamInboxWriter.ts | 1 + .../team/dialogs/CreateTeamDialog.tsx | 71 ++++++++++++++----- 8 files changed, 62 insertions(+), 21 deletions(-) rename .dockerignore => docker/.dockerignore (100%) rename Dockerfile => docker/Dockerfile (100%) rename docker-compose.yml => docker/docker-compose.yml (87%) rename CHANGELOG.md => docs/CHANGELOG.md (100%) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 3ea3b06c..c23e4b37 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -26,11 +26,11 @@ In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-update For maximum trust, run the Docker container with `--network none`: ```bash -docker build -t claude-agent-teams-ui . +docker build -t claude-agent-teams-ui -f docker/Dockerfile . docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui ``` -Or with Docker Compose, uncomment `network_mode: "none"` in `docker-compose.yml`. +Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compose.yml`. ## IPC & Input Validation diff --git a/.dockerignore b/docker/.dockerignore similarity index 100% rename from .dockerignore rename to docker/.dockerignore diff --git a/Dockerfile b/docker/Dockerfile similarity index 100% rename from Dockerfile rename to docker/Dockerfile diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 87% rename from docker-compose.yml rename to docker/docker-compose.yml index 28891ad7..db820738 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,7 @@ # Claude Agent Teams UI — Docker Compose # # Quick start: -# docker compose up +# docker compose -f docker/docker-compose.yml up # # Then open http://localhost:3456 in your browser. # @@ -14,7 +14,9 @@ services: claude-agent-teams-ui: - build: . + build: + context: .. + dockerfile: docker/Dockerfile ports: - "3456:3456" volumes: diff --git a/CHANGELOG.md b/docs/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to docs/CHANGELOG.md diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 60b3b767..74cb5f60 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -413,6 +413,7 @@ function sendInboxMessage(paths, teamName, flags) { : String(Date.now()) + '-' + String(Math.random()); const payload = { from, + to, text, timestamp: nowIso(), read: false, diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 3fa5badf..cfb606be 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -15,6 +15,7 @@ export class TeamInboxWriter { const payload: InboxMessage = { from: request.from ?? 'user', + to: request.member, text: request.text, timestamp: new Date().toISOString(), read: false, diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f60aaa07..9aea9e3e 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -142,6 +142,28 @@ function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] { .filter((member): member is NonNullable => member !== null); } +// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS +const TEAM_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; + +function validateTeamNameInline(name: string): string | null { + const trimmed = name.trim(); + if (!trimmed) return null; + if (!TEAM_NAME_RE.test(trimmed) || trimmed.length > 64) { + return 'Use kebab-case [a-z0-9-], max 64 chars'; + } + return null; +} + +function validateMemberNameInline(name: string): string | null { + const trimmed = name.trim(); + if (!trimmed) return null; + if (!MEMBER_NAME_RE.test(trimmed)) { + return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars'; + } + return null; +} + function validateRequest( request: TeamCreateRequest, options?: { requireCwd?: boolean } @@ -692,6 +714,8 @@ export const CreateTeamDialog = ({ /> {existingTeamNames.includes(teamName.trim()) ? (

Team name already exists

+ ) : validateTeamNameInline(teamName) ? ( +

{validateTeamNameInline(teamName)}

) : fieldErrors.teamName ? (

{fieldErrors.teamName}

) : null} @@ -727,20 +751,27 @@ export const CreateTeamDialog = ({ borderLeftColor: memberColorSet.border, }} > - updateMemberName(member.id, event.target.value)} - placeholder="member-name" - style={ - member.name.trim() - ? { - color: memberColorSet.text, - } - : undefined - } - /> +
+ updateMemberName(member.id, event.target.value)} + placeholder="member-name" + style={ + member.name.trim() + ? { + color: memberColorSet.text, + } + : undefined + } + /> + {validateMemberNameInline(member.name) ? ( +

+ {validateMemberNameInline(member.name)} +

+ ) : null} +
setSubjectDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void saveSubject(); + if (e.key === 'Escape') setEditingSubject(false); + }} + onBlur={() => void saveSubject()} + disabled={savingSubject} + className="h-8 text-base" + /> + {savingSubject ? : null} +
+ ) : ( + + {currentTask.subject} + + + )} {currentTask.activeForm ? ( {currentTask.activeForm} ) : null} @@ -317,12 +414,106 @@ export const TaskDetailDialog = ({ {/* Description */} } defaultOpen> - {currentTask.description ? ( -
+ {editingDescription ? ( +
+
+ + +
+ {descriptionPreview ? ( +
+ {descriptionDraft.trim() ? ( + + ) : ( +

Nothing to preview

+ )} +
+ ) : ( +