diff --git a/README.md b/README.md
index 42c3464f..fae15422 100644
--- a/README.md
+++ b/README.md
@@ -15,17 +15,39 @@
-
+
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 ? (
+
+
+
+ {successMessage}
+ {countdown > 0 && (
+ Closing in {countdown}s...
+ )}
+
+
+ ) : (
+
+
+
+
+ {failureMessage}{' '}
+ (exit code {exited})
+
+
+ Check terminal output above for details
+
+
+
+ )}
+
+
+
+ {/* Progress bar for auto-close countdown */}
+ {countdown > 0 && (
+
+ )}
)}